From e83b1518d7452b34ed9e0b449951a8b2ed0442aa Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Wed, 29 Oct 2025 13:04:49 +0000 Subject: [PATCH] docs(refactor): add integration point refactor analysis and implementation plan Add comprehensive documentation and implementation artifacts for refactoring the plugin to use app-provided content fetchers instead of hardcoded TimeSafari integration. Changes: - Add integration-point-refactor-analysis.md with complete ADR, interfaces, migration plan, and 7-PR breakdown - Add INTEGRATION_REFACTOR_QUICK_START.md for quick reference on new machines - Add src/types/content-fetcher.ts with TypeScript SPI interfaces - Add examples/native-fetcher-android.kt with Kotlin implementation skeleton - Add examples/js-fetcher-typescript.ts with TypeScript implementation skeleton - Add tests/fixtures/test-contract.json for golden contract testing Architecture Decisions: - Dual-path SPI: Native Fetcher (background) + JS Fetcher (foreground only) - Background reliability: Native SPI only, no JS bridging in workers - Reversibility: Legacy code behind feature flag for one minor release - Test contract: Single JSON fixture for both fetcher paths This provides complete specification for implementing the refactor in 7 PRs, starting with SPI shell and progressing through background workers, deduplication, failure policies, and finally legacy code removal. All documentation is self-contained and ready for implementation on any machine. --- docs/INTEGRATION_REFACTOR_QUICK_START.md | 268 ++++++ docs/integration-point-refactor-analysis.md | 853 ++++++++++++++++++++ examples/js-fetcher-typescript.ts | 156 ++++ examples/native-fetcher-android.kt | 153 ++++ src/types/content-fetcher.ts | 135 ++++ tests/fixtures/test-contract.json | 70 ++ 6 files changed, 1635 insertions(+) create mode 100644 docs/INTEGRATION_REFACTOR_QUICK_START.md create mode 100644 docs/integration-point-refactor-analysis.md create mode 100644 examples/js-fetcher-typescript.ts create mode 100644 examples/native-fetcher-android.kt create mode 100644 src/types/content-fetcher.ts create mode 100644 tests/fixtures/test-contract.json diff --git a/docs/INTEGRATION_REFACTOR_QUICK_START.md b/docs/INTEGRATION_REFACTOR_QUICK_START.md new file mode 100644 index 0000000..9f14a5e --- /dev/null +++ b/docs/INTEGRATION_REFACTOR_QUICK_START.md @@ -0,0 +1,268 @@ +# Integration Point Refactor - Quick Start Guide + +**Author**: Matthew Raymer +**Date**: 2025-10-29 +**Status**: 🎯 **REFERENCE** - Quick start for implementation on any machine + +## Overview + +This guide helps you get started implementing the Integration Point Refactor on any machine. All planning and specifications are documented in the codebase. + +## Key Documents + +1. **`docs/integration-point-refactor-analysis.md`** - Complete analysis + improvement directive + - Architecture decisions (ADR-001) + - Public interfaces (TypeScript + Kotlin) + - Migration plan (7 PRs) + - Acceptance criteria + - Risk register + +2. **`src/types/content-fetcher.ts`** - TypeScript type definitions + - `NotificationContent` interface + - `JsNotificationContentFetcher` interface + - `SchedulingPolicy` interface + - `FetchContext` interface + +3. **`examples/native-fetcher-android.kt`** - Kotlin native fetcher skeleton + - Complete example implementation + - Registration pattern + - Error handling + +4. **`examples/js-fetcher-typescript.ts`** - TypeScript JS fetcher skeleton + - Foreground fetcher implementation + - Policy configuration example + +5. **`tests/fixtures/test-contract.json`** - Golden test contract + - Shared fixture for both JS and Native fetchers + - Failure scenarios + +## Implementation Roadmap (7 PRs) + +### PR1 β€” SPI Shell +**Status**: Ready to start +**Files to create**: +- Kotlin/Swift interface definitions +- TypeScript types (already in `src/types/content-fetcher.ts`) +- `SchedulingPolicy` implementation (plugin side) +- No behavior change yet + +**Reference**: Section B in analysis doc + +### PR2 β€” Background Workers +**Status**: After PR1 +**Tasks**: +- Wire Native SPI in WorkManager/BGTasks +- Metrics skeleton +- Cache store + +**Reference**: Section C1 (Background Reliability) + +### PR3 β€” JS Fetcher Path +**Status**: After PR2 +**Tasks**: +- Foreground bridge +- Event bus +- Dev harness screen + +**Reference**: Section B1 (TypeScript interfaces) + +### PR4 β€” Dedupe/Idempotency +**Status**: After PR3 +**Tasks**: +- Bloom/filter store +- `dedupeKey` enforcement +- Tests + +**Reference**: Section C3 (Idempotency & De-duplication) + +### PR5 β€” Failure Policy +**Status**: After PR4 +**Tasks**: +- Retry taxonomy (Section E) +- Backoff implementation +- Cache fallback +- Structured errors + +### PR6 β€” Docs & Samples +**Status**: After PR5 +**Tasks**: +- INTEGRATION_GUIDE.md (when to use which fetcher) +- SCHEDULING_POLICY.md (all configuration knobs) +- Copy-paste snippets + +### PR7 β€” Feature Flag Legacy +**Status**: After PR6 +**Tasks**: +- Guard legacy TimeSafari code behind flag +- Regression tests +- Changelog for major removal + +**Reference**: Section H (Migration Plan) + +## Architecture Decisions + +### ADR-001: Dual-Path SPI +- **Native Fetcher** (Kotlin/Swift) - Required for background +- **JS Fetcher** (TypeScript) - Optional, foreground only +- Legacy code behind feature flag for one minor release + +See: Section A in analysis doc + +## Interface Contracts + +### TypeScript (Host App) +```typescript +// From src/types/content-fetcher.ts +interface JsNotificationContentFetcher { + fetchContent(context: FetchContext): Promise; +} + +// Plugin API additions +interface DailyNotificationPlugin { + setJsContentFetcher(fetcher: JsNotificationContentFetcher): void; + enableNativeFetcher(enable: boolean): Promise; + setPolicy(policy: SchedulingPolicy): Promise; +} +``` + +### Android SPI (Native) +```kotlin +interface NativeNotificationContentFetcher { + suspend fun fetchContent(context: FetchContext): List +} +``` + +See: Section B in analysis doc + +## Key Constraints + +### Background Workers +- **Android**: WorkManager - NO JS bridging, native SPI only +- **iOS**: BGAppRefreshTask/BGProcessingTask - native SPI only +- **Tasks**: Keep < 30s, respect backoff + +### Security +- JWT generation in host app only +- No TimeSafari DTOs in plugin +- Tokens never serialized through JS in background + +### Deduplication +- Use `dedupeKey` (fallback `id`) +- Bloom/filter for recent keys +- Horizon: `dedupeHorizonMs` (default 24h) + +See: Sections C, D in analysis doc + +## Failure Handling + +| Class | Examples | Behavior | +|-------|----------|----------| +| Retryable | 5xx, network, timeout | Backoff per policy | +| Unretryable | 4xx auth, schema error | Log, skip batch | +| Partial | Some items bad | Enqueue valid subset | +| Over-quota | 429 | Honor Retry-After | + +See: Section E in analysis doc + +## Testing Requirements + +### Golden Contract +- Single JSON fixture (`tests/fixtures/test-contract.json`) +- Run through both JS and Native fetchers +- Compare normalized outputs +- Contract tests must pass + +### Failure Scenarios +- Network chaos (drop, latency) +- 401 β†’ refresh β†’ 200 +- 429 with Retry-After +- Malformed items (skip, don't crash) + +See: Section G in analysis doc + +## Acceptance Criteria + +Before considering this complete: + +- βœ… Native fetcher runs with app killed +- βœ… JS fetcher **never** required for background +- βœ… Same inputs β†’ same outputs (parity tests green) +- βœ… No TimeSafari imports/types in plugin +- βœ… No duplicate notifications within `dedupeHorizonMs` +- βœ… Metrics visible, error classes distinguished +- βœ… Docs include copy-paste snippets +- βœ… Battery/quotas respected + +See: Section J in analysis doc + +## Open Questions (Resolve Before PR3) + +1. **iOS capabilities**: Which BG task identifiers/intervals acceptable? +2. **Token hardening**: DPoP or JWE now or later? +3. **User controls**: Per-plan snooze/mute in metadata? + +See: Section K in analysis doc + +## Getting Started Checklist + +When starting on a new machine: + +- [ ] Read `docs/integration-point-refactor-analysis.md` (full context) +- [ ] Review `src/types/content-fetcher.ts` (TypeScript interfaces) +- [ ] Review `examples/native-fetcher-android.kt` (Kotlin pattern) +- [ ] Review `examples/js-fetcher-typescript.ts` (JS pattern) +- [ ] Review `tests/fixtures/test-contract.json` (test contract) +- [ ] Start with **PR1 - SPI Shell** (no behavior change) +- [ ] Follow 7-PR plan sequentially +- [ ] Run contract tests after each PR + +## File Locations + +``` +docs/ + integration-point-refactor-analysis.md # Complete spec + INTEGRATION_REFACTOR_QUICK_START.md # This file + +src/types/ + content-fetcher.ts # TypeScript interfaces + +examples/ + native-fetcher-android.kt # Kotlin example + js-fetcher-typescript.ts # TypeScript example + +tests/fixtures/ + test-contract.json # Golden contract +``` + +## Next Actions + +1. **Start PR1** (SPI Shell) + - Create Kotlin interface in plugin + - Create Swift interface (if iOS support needed) + - Wire up TypeScript types (already done) + - Add `SchedulingPolicy` to plugin + +2. **Review examples** before implementing + - Understand data flow + - See error handling patterns + - Note registration points + +3. **Plan testing** early + - Set up contract test infrastructure + - Prepare mock fetchers for testing + +## Questions? + +All specifications are in `docs/integration-point-refactor-analysis.md`. Refer to: +- Section B for interfaces +- Section C for policies +- Section E for failure handling +- Section I for PR breakdown +- Section J for acceptance criteria + +--- + +**Status**: Reference guide +**Last Updated**: 2025-10-29 +**Maintainer**: Plugin development team + diff --git a/docs/integration-point-refactor-analysis.md b/docs/integration-point-refactor-analysis.md new file mode 100644 index 0000000..2c8b4aa --- /dev/null +++ b/docs/integration-point-refactor-analysis.md @@ -0,0 +1,853 @@ +# Integration Point Refactor Analysis + +**Author**: Matthew Raymer +**Date**: 2025-10-29 +**Status**: 🎯 **ANALYSIS** - Architectural refactoring proposal + +## Objective + +Refactor the Daily Notification Plugin architecture so that **TimeSafari-specific integration logic is implemented by the Capacitor host app** rather than hardcoded in the plugin. This makes the plugin generic and reusable for other applications. + +## Current Architecture + +### Plugin-Side Integration (Current State) + +The plugin currently contains TimeSafari-specific code: + +1. **`TimeSafariIntegrationManager.java`** + - Manages API server URL and DID lifecycle + - Coordinates fetching and scheduling + - Converts TimeSafari data to `NotificationContent` + +2. **`EnhancedDailyNotificationFetcher.java`** + - Makes HTTP calls to TimeSafari API endpoints: + - `POST /api/v2/offers/person` + - `POST /api/v2/offers/plans` + - `POST /api/v2/report/plansLastUpdatedBetween` + - Handles JWT authentication + - Parses TimeSafari response formats + +3. **`DailyNotificationJWTManager.java`** + - Generates JWT tokens for TimeSafari API authentication + - Manages JWT expiration and refresh + +4. **TimeSafari Data Models** + - Plugin imports `PlanSummary`, `OffersResponse`, `TimeSafariNotificationBundle` + - Converts TimeSafari data structures to `NotificationContent[]` + +### Current Data Flow + +``` +Host App (Capacitor) + ↓ configure() with activeDidIntegration config +Plugin (DailyNotificationPlugin) + ↓ initializes TimeSafariIntegrationManager +TimeSafariIntegrationManager + ↓ creates EnhancedDailyNotificationFetcher +EnhancedDailyNotificationFetcher + ↓ makes HTTP calls with JWT +TimeSafari API Server + ↓ returns TimeSafariNotificationBundle +EnhancedDailyNotificationFetcher + ↓ converts to NotificationContent[] +TimeSafariIntegrationManager + ↓ saves & schedules +Plugin Storage & Scheduler +``` + +## Proposed Architecture + +### App-Side Integration (Target State) + +The plugin becomes a **generic notification scheduler** that accepts a **content fetcher callback** from the host app: + +1. **Plugin exposes interface** for content fetching +2. **Host app implements** the TimeSafari API integration +3. **Plugin calls app's implementation** when fetching is needed +4. **Plugin remains generic** - no TimeSafari-specific code + +### Proposed Data Flow + +``` +Host App (Capacitor) + ↓ implements ContentFetcher interface + ↓ provides fetcher callback to plugin +Plugin (DailyNotificationPlugin) + ↓ calls app's fetcher callback +Host App's TimeSafari Integration + ↓ makes HTTP calls, handles JWT, parses responses +TimeSafari API Server + ↓ returns TimeSafariNotificationBundle +Host App's Integration + ↓ converts to NotificationContent[] + ↓ returns to plugin +Plugin + ↓ saves & schedules (no TimeSafari knowledge) +Plugin Storage & Scheduler +``` + +## What Needs to Change + +### 1. Plugin Interface Changes + +#### New TypeScript Interface + +```typescript +/** + * Content fetcher callback that host app must implement + */ +export interface NotificationContentFetcher { + /** + * Fetch notification content from external source + * + * This is called by the plugin when: + * - Background fetch work is triggered + * - Prefetch is scheduled before notification time + * - Manual refresh is requested + * + * @param context Context about why fetch was triggered + * @returns Promise with notification content array + */ + fetchContent(context: FetchContext): Promise; +} + +export interface FetchContext { + /** + * Why the fetch was triggered + */ + trigger: 'background_work' | 'prefetch' | 'manual' | 'scheduled'; + + /** + * Scheduled notification time (millis since epoch) + */ + scheduledTime?: number; + + /** + * When fetch was triggered (millis since epoch) + */ + fetchTime: number; + + /** + * Any additional context from plugin + */ + metadata?: Record; +} +``` + +#### New Plugin Method + +```typescript +export interface DailyNotificationPlugin { + // ... existing methods ... + + /** + * Register content fetcher callback from host app + * + * @param fetcher Implementation provided by host app + */ + setContentFetcher(fetcher: NotificationContentFetcher): void; +} +``` + +### 2. Android Plugin Changes + +#### New Java Interface + +```java +/** + * Content fetcher interface for host app to implement + */ +public interface NotificationContentFetcher { + /** + * Fetch notification content from external source + * + * Called by plugin when background fetch is needed. + * Host app implements TimeSafari API calls here. + * + * @param context Fetch context with trigger info + * @return Future with list of notification content + */ + CompletableFuture> fetchContent( + FetchContext context + ); +} + +public class FetchContext { + public final String trigger; // "background_work", "prefetch", "manual", "scheduled" + public final Long scheduledTime; // Optional: when notification is scheduled + public final long fetchTime; // When fetch was triggered + public final Map metadata; // Additional context + + // Constructor, getters... +} +``` + +#### Plugin Implementation + +```java +public class DailyNotificationPlugin extends Plugin { + private @Nullable NotificationContentFetcher contentFetcher; + + @PluginMethod + public void setContentFetcher(PluginCall call) { + // Receive callback reference from JavaScript + // Store for use in background workers + // Implementation depends on how to bridge JS -> Java callback + } + + // In DailyNotificationFetchWorker: + private NotificationContent fetchContentWithTimeout() { + if (contentFetcher == null) { + // Fallback: return null, use cached content + return null; + } + + FetchContext context = new FetchContext( + "background_work", + scheduledTime, + System.currentTimeMillis(), + metadata + ); + + try { + List results = + contentFetcher.fetchContent(context).get(30, TimeUnit.SECONDS); + return results.isEmpty() ? null : results.get(0); + } catch (Exception e) { + // Handle timeout, errors + return null; + } + } +} +``` + +### 3. What Gets Removed from Plugin + +#### Delete These Classes: + +- `TimeSafariIntegrationManager.java` - Move logic to host app +- `EnhancedDailyNotificationFetcher.java` - Move to host app +- TimeSafari-specific data model imports + +#### Keep These (Generic): + +- `DailyNotificationStorage` - Generic storage +- `DailyNotificationScheduler` - Generic scheduling +- `DailyNotificationPlugin` - Core plugin (modified) +- `DailyNotificationFetchWorker` - Modified to call callback +- `NotificationContent` - Generic data model + +### 4. Host App Implementation + +#### TypeScript Implementation + +```typescript +import { DailyNotification, NotificationContentFetcher, FetchContext } + from '@timesafari/daily-notification-plugin'; +import { TimeSafariAPI } from './timesafari-api'; // Host app's API client + +class TimeSafariContentFetcher implements NotificationContentFetcher { + constructor( + private api: TimeSafariAPI, + private activeDid: string + ) {} + + async fetchContent(context: FetchContext): Promise { + // 1. Generate JWT for authentication + const jwt = await this.api.generateJWT(this.activeDid); + + // 2. Fetch TimeSafari data + const [offersToPerson, offersToProjects, projectUpdates] = + await Promise.all([ + this.api.fetchOffersToPerson(jwt), + this.api.fetchOffersToProjects(jwt), + this.api.fetchProjectsLastUpdated(this.starredPlanIds, jwt) + ]); + + // 3. Convert TimeSafari data to NotificationContent[] + const contents: NotificationContent[] = []; + + // Convert offers to person + if (offersToPerson?.data) { + for (const offer of offersToPerson.data) { + contents.push({ + id: `offer_${offer.id}`, + title: `New Offer: ${offer.title}`, + body: offer.description || '', + scheduledTime: context.scheduledTime || Date.now() + 3600000, + fetchTime: context.fetchTime, + mediaUrl: offer.imageUrl + }); + } + } + + // Convert project updates + if (projectUpdates?.data) { + for (const update of projectUpdates.data) { + contents.push({ + id: `project_${update.planSummary.jwtId}`, + title: `${update.planSummary.name} Updated`, + body: `New updates for ${update.planSummary.name}`, + scheduledTime: context.scheduledTime || Date.now() + 3600000, + fetchTime: context.fetchTime, + mediaUrl: update.planSummary.image + }); + } + } + + return contents; + } +} + +// In host app initialization: +const plugin = new DailyNotification(); +const fetcher = new TimeSafariContentFetcher(apiClient, activeDid); +plugin.setContentFetcher(fetcher); +``` + +#### Android Implementation (if needed) + +If you need native Android implementation (for WorkManager background tasks): + +```kotlin +// In host app's native code +class TimeSafariContentFetcher : NotificationContentFetcher { + private val apiClient: TimeSafariAPIClient + private val activeDid: String + + override fun fetchContent(context: FetchContext): CompletableFuture> { + return CompletableFuture.supplyAsync { + // Make TimeSafari API calls + // Convert to NotificationContent[] + // Return list + } + } +} +``` + +## Implementation Challenges + +### Challenge 1: JavaScript β†’ Java Callback Bridge + +**Problem**: Capacitor plugins need to bridge JavaScript callbacks to Java for background workers. + +**Solutions**: +1. **Bridge via Plugin Method** (Simplest) + - Host app calls plugin method with callback ID + - Plugin stores callback reference + - Plugin calls back via Capacitor bridge when needed + - Limitations: Callback must be serializable + +2. **Bridge via Event System** (More Flexible) + - Plugin emits events when fetch needed + - Host app listens and responds + - Plugin waits for response + - More complex but more flexible + +3. **Native Implementation** (Best Performance) + - Host app provides native Java/Kotlin implementation + - Direct Java interface (no JS bridge needed) + - Best for background work + - Requires host app to include native code + +**Recommendation**: Start with Option 1 (Plugin Method), add Option 3 (Native) later if needed. + +### Challenge 2: Background Worker Execution + +**Problem**: Background WorkManager workers need to call host app's fetcher, but WorkManager runs in separate process. + +**Solutions**: +1. **Store callback reference in SharedPreferences** (Limited) + - Serialize callback config, not actual callback + - Host app provides API endpoint URL + auth info + - Plugin makes HTTP call to host app's endpoint + - Host app handles TimeSafari logic server-side + +2. **Use Capacitor Background Tasks** (Platform-specific) + - iOS: BGTaskScheduler + - Android: WorkManager with Capacitor bridge + - Bridge JS callback from background task + - Complex but works + +3. **Native Background Implementation** (Recommended) + - Host app provides native Java/Kotlin fetcher + - No JS bridge needed in background + - Direct Java interface + - Best performance + +**Recommendation**: Option 3 (Native) for background work, Option 1 for foreground. + +### Challenge 3: Data Model Conversion + +**Problem**: Plugin's generic `NotificationContent` vs TimeSafari's specific data structures. + +**Solution**: +- Host app owns conversion logic +- Plugin never sees TimeSafari data structures +- Plugin only knows about `NotificationContent[]` + +This is actually **simpler** than current architecture. + +### Challenge 4: Configuration Management + +**Problem**: Plugin currently manages TimeSafari config (apiServer, activeDid, starredPlanIds). + +**Solution**: +- Host app manages all TimeSafari config +- Plugin receives only fetcher callback +- Host app provides config to fetcher, not plugin +- Plugin becomes stateless regarding TimeSafari + +## Migration Path + +### Phase 1: Add Callback Interface (Non-Breaking) + +1. Add `setContentFetcher()` method to plugin +2. Add callback interface definitions +3. Keep existing TimeSafari integration as fallback +4. Allow host app to opt-in to callback approach + +### Phase 2: Move TimeSafari Code to Host App + +1. Create `TimeSafariContentFetcher` in host app +2. Migrate `EnhancedDailyNotificationFetcher` logic +3. Migrate JWT generation logic +4. Test with callback approach + +### Phase 3: Remove Plugin Integration (Breaking) + +1. Remove `TimeSafariIntegrationManager` from plugin +2. Remove `EnhancedDailyNotificationFetcher` from plugin +3. Remove TimeSafari data model imports +4. Make callback approach required +5. Update documentation + +## Effort Estimation + +### Plugin Changes + +- **Add callback interface**: 4-8 hours +- **Modify fetch worker**: 4-8 hours +- **Update documentation**: 2-4 hours +- **Testing**: 8-16 hours +- **Total**: 18-36 hours + +### Host App Changes + +- **Create TimeSafariContentFetcher**: 8-16 hours +- **Migrate API client logic**: 8-16 hours +- **Migrate JWT logic**: 4-8 hours +- **Testing**: 8-16 hours +- **Total**: 28-56 hours + +### Total Effort + +**Estimated**: 46-92 hours (6-12 days) for complete migration + +## Benefits + +1. **Generic Plugin**: Plugin can be used by any app, not just TimeSafari +2. **Clear Separation**: Plugin handles scheduling, app handles fetching +3. **Easier Testing**: Mock fetcher for unit tests +4. **Better Maintainability**: TimeSafari changes don't require plugin updates +5. **Flexibility**: Host app controls all API integration details + +## Risks + +1. **Breaking Changes**: Requires migration of host app +2. **Complexity**: Callback bridge adds complexity +3. **Performance**: JavaScript bridge may be slower for background work +4. **Testing Burden**: More complex integration testing + +## Recommendations + +### Short Term (Keep Current Architecture) + +**Recommendation**: Keep current architecture for now, add callback interface as optional enhancement. + +**Rationale**: +- Current architecture works +- Migration is significant effort +- Benefits may not justify costs yet +- Can add callback interface without removing existing code + +### Long Term (Full Refactor) + +**Recommendation**: Plan for full refactor when: +- Plugin needs to support multiple apps +- TimeSafari API changes frequently +- Plugin team wants to decouple from TimeSafari + +**Approach**: +1. Phase 1: Add callback interface alongside existing code +2. Migrate TimeSafari app to use callbacks +3. Phase 3: Remove old integration code + +## Alternative: Hybrid Approach + +Keep TimeSafari integration in plugin but make it **optional and pluggable**: + +```typescript +interface DailyNotificationPlugin { + // Current approach (TimeSafari-specific) + configure(options: ConfigureOptions): void; + + // New approach (generic callback) + setContentFetcher(fetcher: NotificationContentFetcher): void; + + // Plugin uses callback if provided, falls back to TimeSafari integration +} +``` + +This allows: +- Existing apps continue working (no breaking changes) +- New apps can use callback approach +- Plugin gradually becomes generic + +## Questions to Resolve + +1. **Background Work**: How to call JS callback from background WorkManager? + - Native implementation required? + - Or HTTP callback to host app endpoint? + +2. **Error Handling**: How does plugin handle fetcher errors? + - Retry logic? + - Fallback to cached content? + - Event notifications? + +3. **Configuration**: Where does TimeSafari config live? + - Host app only? + - Or plugin still needs some config? + +4. **Testing**: How to test plugin without TimeSafari integration? + - Mock fetcher interface? + - Test fixtures? + +--- + +## Improvement Directive Integration + +This analysis has been superseded by the **Improvement Directive β€” Daily Notification Plugin "Integration Point" Refactor** (see below). The directive provides concrete, implementable specifications with clear interfaces, test contracts, and migration paths. + +### Key Directive Decisions + +1. **Dual-Path SPI**: Native Fetcher (required for background) + Optional JS Fetcher (foreground only) +2. **Background Reliability**: Native SPI only; JS fetcher explicitly disabled in background workers +3. **Reversibility**: Legacy TimeSafari code behind feature flag for one minor release +4. **Test Contract**: Single JSON fixture contract used by both native and JS paths + +See full directive below for complete specifications. + +--- + +## Improvement Directive β€” Daily Notification Plugin "Integration Point" Refactor + +**Audience:** Plugin & TimeSafari app teams +**Goal:** Convert the analysis into an implementable, low-risk refactor with crisp interfaces, background-compatibility, testability, and a reversible migration path. +**Source reviewed:** `docs/integration-point-refactor-analysis.md` + +--- + +## A) Decision Record (ADR-001): Architecture Shape + +**Decision:** Ship a **dual-path SPI** where the plugin exposes: + +1. a **Native Fetcher SPI** (Android/Kotlin, iOS/Swift) for background reliability, and +2. an **Optional JS Fetcher** for foreground/manual refresh & prototyping. + +**Why:** Your analysis correctly targets decoupling, but the **WorkManager/BGTaskScheduler reality** means **native fetchers are the only dependable background path**. The JS callback remains useful for manual refresh, prefetch while the app is foregrounded, or rapid iteration. + +**Reversibility:** Keep the legacy TimeSafari path behind a feature flag for one minor release; remove in the next major. + +**Exit Criteria:** Both fetcher paths produce identical `NotificationContent[]` and pass the same test contract. + +--- + +## B) Public Interfaces (Final Form) + +### B1. TypeScript (Host App) + +```typescript +// core types +export interface NotificationContent { + id: string; + title: string; + body?: string; + scheduledTime?: number; // epoch ms + fetchTime: number; // epoch ms + mediaUrl?: string; + ttlSeconds?: number; // cache validity + dedupeKey?: string; // for idempotency + priority?: 'min'|'low'|'default'|'high'|'max'; + metadata?: Record; +} + +export type FetchTrigger = 'background_work' | 'prefetch' | 'manual' | 'scheduled'; + +export interface FetchContext { + trigger: FetchTrigger; + scheduledTime?: number; + fetchTime: number; + metadata?: Record; +} + +export interface JsNotificationContentFetcher { + fetchContent(context: FetchContext): Promise; +} +``` + +**Plugin API Additions** + +```typescript +export interface DailyNotificationPlugin { + setJsContentFetcher(fetcher: JsNotificationContentFetcher): void; // foreground-only + enableNativeFetcher(enable: boolean): Promise; // default true + setPolicy(policy: SchedulingPolicy): Promise; // see Β§C2 +} +``` + +### B2. Android SPI (Host App native) + +```kotlin +interface NativeNotificationContentFetcher { + suspend fun fetchContent(context: FetchContext): List +} + +data class FetchContext( + val trigger: String, // enum mirror + val scheduledTime: Long?, // epoch ms + val fetchTime: Long, // epoch ms + val metadata: Map = emptyMap() +) +``` + +**Registration:** Host app injects an implementation via a generated ServiceLoader or explicit setter in `Application.onCreate()`: + +```kotlin +DailyNotification.setNativeFetcher(TimeSafariNativeFetcher()) +``` + +> **Note:** JS fetcher remains available but **is not used** by background workers. + +--- + +## C) Scheduling & Policy Guardrails + +### C1. Background Reliability + +* **Android:** WorkManager + `setRequiredNetworkType(CONNECTED)`, exponential backoff, content URI triggers optional, **no JS bridging** in background. +* **iOS:** `BGAppRefreshTask` + `BGProcessingTask` with earliestBeginDate windows; keep tasks short (< 30s), aggregate fetches. + +### C2. `SchedulingPolicy` + +```typescript +export interface SchedulingPolicy { + prefetchWindowMs?: number; // how early to prefetch before schedule + retryBackoff: { minMs: number; maxMs: number; factor: number; jitterPct: number }; + maxBatchSize?: number; // upper bound per run + dedupeHorizonMs?: number; // window for dedupeKey suppression + cacheTtlSeconds?: number; // default if item.ttlSeconds absent + exactAlarmsAllowed?: boolean; // guarded by OS version & battery policy +} +``` + +### C3. Idempotency & De-duplication + +* The plugin maintains a **recent keys bloom/filter** using `dedupeKey` (fallback `id`) for `dedupeHorizonMs`. +* No notification is enqueued if `(dedupeKey, title, body)` unchanged within horizon. + +--- + +## D) Security & Config Partition + +### D1. Secrets & Tokens + +* **JWT generation lives in the host app**, not the plugin. For extra hardening consider **DPoP** or **JWE-wrapped access tokens** if your API supports it. +* Rotate keys; keep key refs in host app secure storage; never serialize tokens through JS bridges in background. + +### D2. Least-Knowledge Plugin + +* Plugin stores only generic `NotificationContent`. +* **No TimeSafari DTOs** in plugin artifacts. (As your analysis recommends.) + +--- + +## E) Failure Taxonomy & Handling + +| Class | Examples | Plugin Behavior | +| --------------- | ---------------------- | ------------------------------------------------- | +| **Retryable** | 5xx, network, timeouts | Backoff per policy; stop on `maxAttempts` | +| **Unretryable** | 4xx auth, schema error | Log & surface event; skip batch; keep schedule | +| **Partial** | Some items bad | Enqueue valid subset; metric + structured error | +| **Over-quota** | 429 | Honor `Retry-After` if present; otherwise backoff | + +**Cache fallback:** If fetch fails, reuse **unexpired cached contents** (by item `ttlSeconds` or default policy). + +--- + +## F) Metrics, Logs, Events + +* **Metrics:** `fetch_duration_ms`, `fetch_success`, `fetch_fail_class`, `items_fetched`, `items_enqueued`, `deduped_count`, `cache_hits`. +* **Structured logs:** Correlate by `run_id`, include `trigger`, backoff stage, and per-item `dedupeKey`. +* **Event bus:** Emit `onFetchStart`, `onFetchEnd`, `onFetchError`, `onEnqueue` (debug UI can subscribe). + +--- + +## G) Test Contract & Fixtures + +### G1. Golden Contract + +A **single JSON fixture contract** used by both native & JS fetchers: + +```json +{ + "context": {"trigger":"scheduled","scheduledTime":1730179200000,"fetchTime":1730175600000}, + "output": [ + {"id":"offer_123","title":"New Offer","body":"...","fetchTime":1730175600000,"dedupeKey":"offer_123","ttlSeconds":86400} + ] +} +``` + +* CI runs the same contract through **(1) JS fetcher** (foreground test harness) and **(2) Native fetcher** (instrumented test) and compares normalized outputs. + +### G2. Failure Sims + +* Network chaos (drop, latency), 401β†’refreshβ†’200, 429 with `Retry-After`, malformed item (should be skipped, not crash). + +--- + +## H) Migration Plan (Minor β†’ Major) + +**Minor (X.Y):** + +1. Add `setJsContentFetcher`, `enableNativeFetcher`, `setPolicy`. +2. Implement **Native Fetcher SPI** & plugin side registry. +3. Keep legacy TimeSafari code behind `useLegacyIntegration` flag (default **false**). + +**Major (X+1.0):** + +1. Remove legacy TimeSafari classes (`TimeSafariIntegrationManager`, `EnhancedDailyNotificationFetcher`, etc.). +2. Publish migration guide & codemod mapping old config β†’ SPI registration. + +--- + +## I) Pull-Request Breakdown (7 PRs) + +1. **PR1 β€” SPI Shell:** Kotlin/Swift interfaces, TS types, `SchedulingPolicy`, no behavior change. +2. **PR2 β€” Background Workers:** Wire Native SPI in WorkManager/BGTasks; metrics skeleton; cache store. +3. **PR3 β€” JS Fetcher Path:** Foreground bridge, event bus, dev harness screen. +4. **PR4 β€” Dedupe/Idempotency:** Bloom/filter store + tests; `dedupeKey` enforcement. +5. **PR5 β€” Failure Policy:** Retry taxonomy, backoff, cache fallback; structured errors. +6. **PR6 β€” Docs & Samples:** Minimal host app examples (JS + Kotlin), migration notes. +7. **PR7 β€” Feature Flag Legacy:** Guard & regression tests; changelog for major removal. + +--- + +## J) Acceptance Criteria (Definition of Done) + +* **Background Reliability:** Native fetcher runs with app killed; JS fetcher **never** required for background. +* **Parity:** Same inputs β†’ same `NotificationContent[]` via JS & Native paths (contract tests green). +* **No TimeSafari in Plugin:** No imports, types, or endpoints leak into plugin modules. +* **Safety:** No notifications emitted twice within `dedupeHorizonMs` for identical `dedupeKey`. +* **Observability:** Metrics visible; error classes distinguish retryable vs. not; logs correlated by run. +* **Docs:** Host app integration guide includes **copy-paste** Kotlin & TS skeletons; migration guide from legacy. +* **Battery & Quotas:** Backoff respected; tasks kept <30s; policy toggles documented; exact alarms gated. + +--- + +## K) Open Questions (Resolve Before PR3) + +1. **iOS capabilities:** Which BG task identifiers and permitted intervals will Product accept (refresh vs processing)? +2. **Token Hardening:** Do we want DPoP or short-lived JWE now or later? +3. **User Controls:** Should we surface a per-plan "snooze/mute" map inside `metadata` with plugin-side persistence? + +--- + +## L) Example Skeletons + +### L1. Android Native Fetcher (Host App) + +```kotlin +class TimeSafariNativeFetcher( + private val api: TimeSafariApi, + private val tokenProvider: TokenProvider +) : NativeNotificationContentFetcher { + + override suspend fun fetchContent(context: FetchContext): List { + val jwt = tokenProvider.freshToken() + val updates = api.updates(jwt) // domain-specific + val offers = api.offers(jwt) + + return buildList { + for (o in offers) add( + NotificationContent( + id = "offer_${o.id}", + title = "New Offer: ${o.title}", + body = o.description ?: "", + scheduledTime = context.scheduledTime ?: (System.currentTimeMillis() + 3_600_000), + fetchTime = context.fetchTime, + mediaUrl = o.imageUrl, + ttlSeconds = 86_400, + dedupeKey = "offer_${o.id}" + ) + ) + // … map updates similarly … + } + } +} +``` + +### L2. JS Fetcher Registration (Foreground Only) + +```typescript +const fetcher: JsNotificationContentFetcher = { + async fetchContent(ctx) { + const jwt = await api.generateJWT(activeDid); + const { offers, updates } = await api.fetchEverything(jwt); + return mapToNotificationContent(offers, updates, ctx); + } +}; + +DailyNotification.setJsContentFetcher(fetcher); +await DailyNotification.setPolicy({ + retryBackoff: { minMs: 2000, maxMs: 600000, factor: 2, jitterPct: 20 }, + dedupeHorizonMs: 24*60*60*1000, + cacheTtlSeconds: 6*60*60 +}); +``` + +--- + +## M) Docs to Produce + +* **INTEGRATION_GUIDE.md** (host app: Native vs JS, when to use which, copy-paste snippets). +* **SCHEDULING_POLICY.md** (all knobs with OS caveats). +* **MIGRATION_GUIDE_Xβ†’X+1.md** (flag removal plan, oldβ†’new mapping). +* **TEST_CONTRACTS.md** (fixtures, CI commands). + +--- + +## N) Risk Register & Mitigations + +* **Risk:** JS callback used in background β†’ fetch starves. + **Mitigation:** Disable in background by design; lint rule + runtime guard. +* **Risk:** Duplicate notifications. + **Mitigation:** `dedupeKey` + horizon filter; contract tests to assert no dupes. +* **Risk:** Token leakage through JS. + **Mitigation:** Tokens stay in native path for background; JS path only in foreground; redact logs. +* **Risk:** Major removal shocks dependents. + **Mitigation:** Two-release migration window + codemod and samples. + +--- + +### Final Note + +Your analysis nails the **separation of concerns** and enumerates the **callback bridge challenges**. This directive turns it into a concrete, staged plan with **native-first background fetch**, **JS optionality**, **clear policies**, and **verifiable acceptance criteria** while preserving a **rollback path**. + +--- + +**Status**: Improvement Directive - Active Implementation Plan +**Next Steps**: Begin PR1 (SPI Shell) implementation +**Dependencies**: None +**Stakeholders**: Plugin developers, TimeSafari app developers + diff --git a/examples/js-fetcher-typescript.ts b/examples/js-fetcher-typescript.ts new file mode 100644 index 0000000..501f65e --- /dev/null +++ b/examples/js-fetcher-typescript.ts @@ -0,0 +1,156 @@ +/** + * JavaScript Content Fetcher Implementation (TypeScript) + * + * Example implementation of JsNotificationContentFetcher for foreground/manual refresh. + * + * NOTE: This is used ONLY for foreground operations. Background workers use + * the Native Fetcher SPI (Kotlin/Swift) for reliability. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { DailyNotification } from '@timesafari/daily-notification-plugin'; +import type { + JsNotificationContentFetcher, + FetchContext, + NotificationContent +} from '@timesafari/daily-notification-plugin'; +import { TimeSafariAPI } from './timesafari-api'; // Host app's API client + +/** + * TimeSafari JavaScript content fetcher + * + * Implements JsNotificationContentFetcher for foreground/manual refresh. + * Background workers use the native Kotlin/Swift implementation. + */ +class TimeSafariJsFetcher implements JsNotificationContentFetcher { + constructor( + private api: TimeSafariAPI, + private activeDid: string, + private starredPlanIds: string[] + ) {} + + async fetchContent(context: FetchContext): Promise { + try { + // 1. Generate JWT for authentication + const jwt = await this.api.generateJWT(this.activeDid); + + // 2. Fetch TimeSafari data in parallel + const [offersToPerson, offersToPlans, projectUpdates] = await Promise.all([ + this.api.fetchOffersToPerson(jwt), + this.api.fetchOffersToPlans(jwt), + this.starredPlanIds.length > 0 + ? this.api.fetchProjectsLastUpdated(this.starredPlanIds, jwt) + : Promise.resolve(null) + ]); + + // 3. Convert to NotificationContent array + const contents: NotificationContent[] = []; + + // Convert offers to person + if (offersToPerson?.data) { + for (const offer of offersToPerson.data) { + contents.push({ + id: `offer_person_${offer.id}`, + title: `New Offer: ${offer.title}`, + body: offer.description || '', + scheduledTime: context.scheduledTime || Date.now() + 3600000, + fetchTime: context.fetchTime, + mediaUrl: offer.imageUrl, + ttlSeconds: 86400, // 24 hours + dedupeKey: `offer_person_${offer.id}_${offer.updatedAt}`, + priority: 'default', + metadata: { + offerId: offer.id, + issuerDid: offer.issuerDid, + source: 'offers_to_person' + } + }); + } + } + + // Convert offers to plans + if (offersToPlans?.data) { + for (const offer of offersToPlans.data) { + contents.push({ + id: `offer_plan_${offer.id}`, + title: `Offer for Your Project: ${offer.projectName}`, + body: offer.description || '', + scheduledTime: context.scheduledTime || Date.now() + 3600000, + fetchTime: context.fetchTime, + mediaUrl: offer.imageUrl, + ttlSeconds: 86400, + dedupeKey: `offer_plan_${offer.id}_${offer.updatedAt}`, + priority: 'default', + metadata: { + offerId: offer.id, + planHandleId: offer.planHandleId, + source: 'offers_to_plans' + } + }); + } + } + + // Convert project updates + if (projectUpdates?.data) { + for (const update of projectUpdates.data) { + contents.push({ + id: `project_${update.planSummary.jwtId}`, + title: `${update.planSummary.name} Updated`, + body: `New updates for ${update.planSummary.name}`, + scheduledTime: context.scheduledTime || Date.now() + 3600000, + fetchTime: context.fetchTime, + mediaUrl: update.planSummary.image, + ttlSeconds: 86400, + dedupeKey: `project_${update.planSummary.handleId}_${update.planSummary.jwtId}`, + priority: 'default', + metadata: { + planHandleId: update.planSummary.handleId, + jwtId: update.planSummary.jwtId, + source: 'project_updates' + } + }); + } + } + + return contents; + } catch (error) { + console.error('TimeSafari fetch failed:', error); + // Return empty array - plugin will handle retry based on policy + return []; + } + } +} + +/** + * Register fetcher and configure policy + */ +export async function setupDailyNotification( + api: TimeSafariAPI, + activeDid: string, + starredPlanIds: string[] +): Promise { + // Create JS fetcher for foreground operations + const jsFetcher = new TimeSafariJsFetcher(api, activeDid, starredPlanIds); + DailyNotification.setJsContentFetcher(jsFetcher); + + // Configure scheduling policy + await DailyNotification.setPolicy({ + prefetchWindowMs: 5 * 60 * 1000, // 5 minutes before scheduled time + retryBackoff: { + minMs: 2000, + maxMs: 600000, // 10 minutes max + factor: 2, + jitterPct: 20 + }, + maxBatchSize: 50, + dedupeHorizonMs: 24 * 60 * 60 * 1000, // 24 hours + cacheTtlSeconds: 6 * 60 * 60, // 6 hours default + exactAlarmsAllowed: true // If app has permission + }); + + // Enable native fetcher (required for background workers) + await DailyNotification.enableNativeFetcher(true); +} + diff --git a/examples/native-fetcher-android.kt b/examples/native-fetcher-android.kt new file mode 100644 index 0000000..1c3370a --- /dev/null +++ b/examples/native-fetcher-android.kt @@ -0,0 +1,153 @@ +/** + * TimeSafari Native Fetcher Implementation (Android/Kotlin) + * + * Example implementation of NativeNotificationContentFetcher for Android. + * This runs in background workers and does NOT require JavaScript bridge. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.notification + +import com.timesafari.dailynotification.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * TimeSafari native content fetcher + * + * Implements the NativeNotificationContentFetcher SPI to provide + * TimeSafari-specific notification content fetching for background workers. + */ +class TimeSafariNativeFetcher( + private val api: TimeSafariApi, + private val tokenProvider: TokenProvider, + private val starredPlanIds: List +) : NativeNotificationContentFetcher { + + override suspend fun fetchContent(context: FetchContext): List { + return withContext(Dispatchers.IO) { + try { + // 1. Get fresh authentication token + val jwt = tokenProvider.freshToken() + + // 2. Fetch TimeSafari data in parallel + val offersToPerson = async { api.fetchOffersToPerson(jwt) } + val offersToPlans = async { api.fetchOffersToPlans(jwt) } + val projectUpdates = async { + if (starredPlanIds.isNotEmpty()) { + api.fetchProjectsLastUpdated(starredPlanIds, jwt) + } else { + null + } + } + + // Wait for all requests + val offersPersonResult = offersToPerson.await() + val offersPlansResult = offersToPlans.await() + val updatesResult = projectUpdates.await() + + // 3. Convert to NotificationContent list + buildList { + // Add offers to person + offersPersonResult?.data?.forEach { offer -> + add( + NotificationContent( + id = "offer_person_${offer.id}", + title = "New Offer: ${offer.title}", + body = offer.description ?: "", + scheduledTime = context.scheduledTime + ?: (System.currentTimeMillis() + 3_600_000), + fetchTime = context.fetchTime, + mediaUrl = offer.imageUrl, + ttlSeconds = 86_400, // 24 hours + dedupeKey = "offer_person_${offer.id}_${offer.updatedAt}", + priority = "default", + metadata = mapOf( + "offerId" to offer.id, + "issuerDid" to offer.issuerDid, + "source" to "offers_to_person" + ) + ) + ) + } + + // Add offers to plans + offersPlansResult?.data?.forEach { offer -> + add( + NotificationContent( + id = "offer_plan_${offer.id}", + title = "Offer for Your Project: ${offer.projectName}", + body = offer.description ?: "", + scheduledTime = context.scheduledTime + ?: (System.currentTimeMillis() + 3_600_000), + fetchTime = context.fetchTime, + mediaUrl = offer.imageUrl, + ttlSeconds = 86_400, + dedupeKey = "offer_plan_${offer.id}_${offer.updatedAt}", + priority = "default", + metadata = mapOf( + "offerId" to offer.id, + "planHandleId" to offer.planHandleId, + "source" to "offers_to_plans" + ) + ) + ) + } + + // Add project updates + updatesResult?.data?.forEach { update -> + add( + NotificationContent( + id = "project_${update.planSummary.jwtId}", + title = "${update.planSummary.name} Updated", + body = "New updates for ${update.planSummary.name}", + scheduledTime = context.scheduledTime + ?: (System.currentTimeMillis() + 3_600_000), + fetchTime = context.fetchTime, + mediaUrl = update.planSummary.image, + ttlSeconds = 86_400, + dedupeKey = "project_${update.planSummary.handleId}_${update.planSummary.jwtId}", + priority = "default", + metadata = mapOf( + "planHandleId" to update.planSummary.handleId, + "jwtId" to update.planSummary.jwtId, + "source" to "project_updates" + ) + ) + ) + } + } + } catch (e: Exception) { + // Log error and return empty list + // Plugin will handle retry based on SchedulingPolicy + android.util.Log.e("TimeSafariFetcher", "Fetch failed", e) + emptyList() + } + } + } +} + +/** + * Registration in Application.onCreate() + */ +class TimeSafariApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Register native fetcher + val api = TimeSafariApiClient(context = this) + val tokenProvider = JWTokenProvider(activeDid = getActiveDid()) + val starredPlanIds = getStarredPlanIds() + + val fetcher = TimeSafariNativeFetcher( + api = api, + tokenProvider = tokenProvider, + starredPlanIds = starredPlanIds + ) + + DailyNotification.setNativeFetcher(fetcher) + } +} + diff --git a/src/types/content-fetcher.ts b/src/types/content-fetcher.ts new file mode 100644 index 0000000..5128d40 --- /dev/null +++ b/src/types/content-fetcher.ts @@ -0,0 +1,135 @@ +/** + * Content Fetcher SPI Types + * + * TypeScript definitions for the content fetcher Service Provider Interface + * that allows host apps to provide notification content fetching logic. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +/** + * Notification content item + * + * Core data structure for notifications that the plugin can schedule + * and display. All TimeSafari-specific data must be converted to this + * generic format by the host app's fetcher implementation. + */ +export interface NotificationContent { + /** Unique identifier for this notification */ + id: string; + + /** Notification title (required) */ + title: string; + + /** Notification body text (optional) */ + body?: string; + + /** When this notification should be displayed (epoch ms) */ + scheduledTime?: number; + + /** When this content was fetched (epoch ms, required) */ + fetchTime: number; + + /** Optional image URL for rich notifications */ + mediaUrl?: string; + + /** Cache TTL in seconds (how long this content is valid) */ + ttlSeconds?: number; + + /** Deduplication key (for idempotency) */ + dedupeKey?: string; + + /** Notification priority level */ + priority?: 'min' | 'low' | 'default' | 'high' | 'max'; + + /** Additional metadata (opaque to plugin) */ + metadata?: Record; +} + +/** + * Reason why content fetch was triggered + */ +export type FetchTrigger = + | 'background_work' // Background worker (WorkManager/BGTask) + | 'prefetch' // Prefetch before scheduled notification + | 'manual' // User-initiated refresh + | 'scheduled'; // Scheduled fetch + +/** + * Context provided to fetcher about why fetch was triggered + */ +export interface FetchContext { + /** Why the fetch was triggered */ + trigger: FetchTrigger; + + /** When notification is scheduled (if applicable, epoch ms) */ + scheduledTime?: number; + + /** When fetch was triggered (epoch ms) */ + fetchTime: number; + + /** Additional context from plugin (opaque) */ + metadata?: Record; +} + +/** + * JavaScript Content Fetcher Interface + * + * Host app implements this interface to provide notification content + * fetching logic. This is used ONLY for foreground/manual refresh. + * + * Background workers use Native Fetcher SPI (Kotlin/Swift) for reliability. + */ +export interface JsNotificationContentFetcher { + /** + * Fetch notification content from external source + * + * Called by plugin when: + * - Manual refresh is requested + * - Prefetch is needed while app is foregrounded + * - Testing/debugging + * + * NOT called from background workers (they use Native SPI) + * + * @param context Context about why fetch was triggered + * @returns Promise with array of notification content + */ + fetchContent(context: FetchContext): Promise; +} + +/** + * Scheduling policy configuration + * + * Controls how the plugin schedules fetches, handles retries, + * and manages deduplication. + */ +export interface SchedulingPolicy { + /** How early to prefetch before scheduled notification (ms) */ + prefetchWindowMs?: number; + + /** Retry backoff configuration */ + retryBackoff: { + /** Minimum delay between retries (ms) */ + minMs: number; + /** Maximum delay between retries (ms) */ + maxMs: number; + /** Exponential backoff multiplier */ + factor: number; + /** Jitter percentage (0-100) */ + jitterPct: number; + }; + + /** Maximum items to fetch per batch */ + maxBatchSize?: number; + + /** Deduplication window (ms) - prevents duplicate notifications */ + dedupeHorizonMs?: number; + + /** Default cache TTL if item doesn't specify (seconds) */ + cacheTtlSeconds?: number; + + /** Whether exact alarms are allowed (Android 12+) */ + exactAlarmsAllowed?: boolean; +} + diff --git a/tests/fixtures/test-contract.json b/tests/fixtures/test-contract.json new file mode 100644 index 0000000..a656908 --- /dev/null +++ b/tests/fixtures/test-contract.json @@ -0,0 +1,70 @@ +{ + "description": "Golden contract test fixture - used by both JS and Native fetcher tests", + "version": "1.0.0", + "context": { + "trigger": "scheduled", + "scheduledTime": 1730179200000, + "fetchTime": 1730175600000, + "metadata": { + "testRun": true, + "runId": "test-run-001" + } + }, + "expectedOutput": [ + { + "id": "offer_123", + "title": "New Offer: Community Garden Project", + "body": "A new volunteer opportunity is available", + "scheduledTime": 1730179200000, + "fetchTime": 1730175600000, + "mediaUrl": "https://example.com/images/garden.jpg", + "ttlSeconds": 86400, + "dedupeKey": "offer_123_2025-10-29T12:00:00Z", + "priority": "default", + "metadata": { + "offerId": "123", + "issuerDid": "did:key:test123", + "source": "offers_to_person" + } + }, + { + "id": "project_456", + "title": "Community Center Project Updated", + "body": "New updates for Community Center Project", + "scheduledTime": 1730179200000, + "fetchTime": 1730175600000, + "mediaUrl": null, + "ttlSeconds": 86400, + "dedupeKey": "project_https://endorser.ch/entity/01ABC_789xyz", + "priority": "default", + "metadata": { + "planHandleId": "https://endorser.ch/entity/01ABC", + "jwtId": "789xyz", + "source": "project_updates" + } + } + ], + "failureScenarios": [ + { + "name": "network_timeout", + "simulate": "timeout_5000ms", + "expectedBehavior": "retry_with_backoff" + }, + { + "name": "auth_401", + "simulate": "http_401", + "expectedBehavior": "unretryable_error_logged" + }, + { + "name": "rate_limit_429", + "simulate": "http_429_retry_after_60", + "expectedBehavior": "honor_retry_after_header" + }, + { + "name": "partial_failure", + "simulate": "malformed_item_in_array", + "expectedBehavior": "skip_bad_item_enqueue_valid" + } + ] +} +