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.
This commit is contained in:
268
docs/INTEGRATION_REFACTOR_QUICK_START.md
Normal file
268
docs/INTEGRATION_REFACTOR_QUICK_START.md
Normal file
@@ -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<NotificationContent[]>;
|
||||
}
|
||||
|
||||
// Plugin API additions
|
||||
interface DailyNotificationPlugin {
|
||||
setJsContentFetcher(fetcher: JsNotificationContentFetcher): void;
|
||||
enableNativeFetcher(enable: boolean): Promise<void>;
|
||||
setPolicy(policy: SchedulingPolicy): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Android SPI (Native)
|
||||
```kotlin
|
||||
interface NativeNotificationContentFetcher {
|
||||
suspend fun fetchContent(context: FetchContext): List<NotificationContent>
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
853
docs/integration-point-refactor-analysis.md
Normal file
853
docs/integration-point-refactor-analysis.md
Normal file
@@ -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<NotificationContent[]>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 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<List<NotificationContent>> 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<String, Object> 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<NotificationContent> 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<NotificationContent[]> {
|
||||
// 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<List<NotificationContent>> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
export type FetchTrigger = 'background_work' | 'prefetch' | 'manual' | 'scheduled';
|
||||
|
||||
export interface FetchContext {
|
||||
trigger: FetchTrigger;
|
||||
scheduledTime?: number;
|
||||
fetchTime: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsNotificationContentFetcher {
|
||||
fetchContent(context: FetchContext): Promise<NotificationContent[]>;
|
||||
}
|
||||
```
|
||||
|
||||
**Plugin API Additions**
|
||||
|
||||
```typescript
|
||||
export interface DailyNotificationPlugin {
|
||||
setJsContentFetcher(fetcher: JsNotificationContentFetcher): void; // foreground-only
|
||||
enableNativeFetcher(enable: boolean): Promise<void>; // default true
|
||||
setPolicy(policy: SchedulingPolicy): Promise<void>; // see §C2
|
||||
}
|
||||
```
|
||||
|
||||
### B2. Android SPI (Host App native)
|
||||
|
||||
```kotlin
|
||||
interface NativeNotificationContentFetcher {
|
||||
suspend fun fetchContent(context: FetchContext): List<NotificationContent>
|
||||
}
|
||||
|
||||
data class FetchContext(
|
||||
val trigger: String, // enum mirror
|
||||
val scheduledTime: Long?, // epoch ms
|
||||
val fetchTime: Long, // epoch ms
|
||||
val metadata: Map<String, Any?> = 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<NotificationContent> {
|
||||
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
|
||||
|
||||
156
examples/js-fetcher-typescript.ts
Normal file
156
examples/js-fetcher-typescript.ts
Normal file
@@ -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<NotificationContent[]> {
|
||||
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<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
153
examples/native-fetcher-android.kt
Normal file
153
examples/native-fetcher-android.kt
Normal file
@@ -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<String>
|
||||
) : NativeNotificationContentFetcher {
|
||||
|
||||
override suspend fun fetchContent(context: FetchContext): List<NotificationContent> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
135
src/types/content-fetcher.ts
Normal file
135
src/types/content-fetcher.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<NotificationContent[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
70
tests/fixtures/test-contract.json
vendored
Normal file
70
tests/fixtures/test-contract.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user