Browse Source
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.master
6 changed files with 1635 additions and 0 deletions
@ -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 |
|||
|
|||
@ -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 |
|||
|
|||
@ -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); |
|||
} |
|||
|
|||
@ -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) |
|||
} |
|||
} |
|||
|
|||
@ -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; |
|||
} |
|||
|
|||
@ -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" |
|||
} |
|||
] |
|||
} |
|||
|
|||
Loading…
Reference in new issue