From c1cc8802f6b7115279a76f8531c1270504ebe7a3 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Thu, 30 Oct 2025 10:03:47 +0000 Subject: [PATCH] feat(fetcher): add configureNativeFetcher cross-platform API Add configureNativeFetcher() plugin method to enable TypeScript configuration of native fetchers with API credentials. This provides a cross-platform mechanism for passing configuration from JavaScript to native code without relying on platform-specific storage. - Add configure() method to NativeNotificationContentFetcher interface (optional, defaults to no-op for fetchers that don't need config) - Add configureNativeFetcher plugin method in DailyNotificationPlugin - Add TypeScript definitions and comprehensive JSDoc - Create NATIVE_FETCHER_CONFIGURATION.md documentation - Update TestNativeFetcher to use real API endpoint (10.0.2.2:3000) - Update DemoNativeFetcher Javadoc explaining configure() is optional - Add configureNativeFetcher() call to demo app's configurePlugin() Enables host apps to configure native fetchers from TypeScript, keeping the interface consistent across Android, iOS, and web platforms. --- .../dailynotification/DemoNativeFetcher.java | 12 + .../NativeNotificationContentFetcher.java | 57 ++ docs/NATIVE_FETCHER_CONFIGURATION.md | 576 ++++++++++++++++++ src/definitions.ts | 65 ++ .../test/TestNativeFetcher.java | 274 ++++++++- 5 files changed, 954 insertions(+), 30 deletions(-) create mode 100644 docs/NATIVE_FETCHER_CONFIGURATION.md diff --git a/android/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java b/android/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java index 4bb284e..7418077 100644 --- a/android/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java +++ b/android/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java @@ -25,11 +25,23 @@ import java.util.concurrent.CompletableFuture; * * Returns mock notification content for demonstration. In production host apps, * this would fetch from TimeSafari API or other data sources. + * + *

Configuration:

+ *

This fetcher does NOT override {@code configure()} because it uses hardcoded + * mock data and doesn't need API credentials. The default no-op implementation + * from the interface is sufficient.

+ * + *

For an example that accepts configuration from TypeScript, see + * {@code TestNativeFetcher} in the test app.

*/ public class DemoNativeFetcher implements NativeNotificationContentFetcher { private static final String TAG = "DemoNativeFetcher"; + // Note: We intentionally do NOT override configure() because this fetcher + // uses hardcoded mock data. The default no-op implementation from the + // interface is sufficient. This demonstrates that configure() is optional. + @Override @NonNull public CompletableFuture> fetchContent( diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java b/android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java index 2b58454..b860367 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java @@ -82,5 +82,62 @@ public interface NativeNotificationContentFetcher { */ @NonNull CompletableFuture> fetchContent(@NonNull FetchContext context); + + /** + * Optional: Configure the native fetcher with API credentials and settings + * + *

This method is called by the plugin when {@code configureNativeFetcher} is invoked + * from TypeScript. It provides a cross-platform mechanism for passing configuration + * from the JavaScript layer to native code without using platform-specific storage + * mechanisms.

+ * + *

When to implement:

+ *
    + *
  • Your fetcher needs API credentials (URL, authentication tokens, etc.)
  • + *
  • Configuration should come from TypeScript/JavaScript code (e.g., from app config)
  • + *
  • You want to avoid hardcoding credentials in native code
  • + *
+ * + *

When to skip (use default no-op):

+ *
    + *
  • Your fetcher gets credentials from platform-specific storage (SharedPreferences, Keychain, etc.)
  • + *
  • Your fetcher has hardcoded test credentials
  • + *
  • Configuration is handled internally and doesn't need external input
  • + *
+ * + *

Thread Safety: This method may be called from any thread. Implementations + * must be thread-safe if storing configuration in instance variables.

+ * + *

Implementation Pattern:

+ *
{@code
+     * private volatile String apiBaseUrl;
+     * private volatile String activeDid;
+     * private volatile String jwtSecret;
+     * 
+     * @Override
+     * public void configure(String apiBaseUrl, String activeDid, String jwtSecret) {
+     *     this.apiBaseUrl = apiBaseUrl;
+     *     this.activeDid = activeDid;
+     *     this.jwtSecret = jwtSecret;
+     *     Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl);
+     * }
+     * }
+ * + * @param apiBaseUrl Base URL for API server. Examples: + * - Android emulator: "http://10.0.2.2:3000" (maps to host localhost:3000) + * - iOS simulator: "http://localhost:3000" + * - Production: "https://api.timesafari.com" + * @param activeDid Active DID (Decentralized Identifier) for authentication. + * Used as the JWT issuer/subject. Format: "did:ethr:0x..." + * @param jwtSecret JWT secret key for signing authentication tokens. + * Keep this secure - consider using secure storage for production. + * + * @see DailyNotificationPlugin#configureNativeFetcher(PluginCall) + */ + default void configure(String apiBaseUrl, String activeDid, String jwtSecret) { + // Default no-op implementation - fetchers that need config can override + // This allows fetchers that don't need TypeScript-provided configuration + // to ignore this method without implementing an empty body. + } } diff --git a/docs/NATIVE_FETCHER_CONFIGURATION.md b/docs/NATIVE_FETCHER_CONFIGURATION.md new file mode 100644 index 0000000..71d3e4d --- /dev/null +++ b/docs/NATIVE_FETCHER_CONFIGURATION.md @@ -0,0 +1,576 @@ +# Native Fetcher Configuration Guide + +**Author**: Matthew Raymer +**Date**: 2025-01-28 +**Status**: 🎯 **REFERENCE** - Complete guide for configuring native fetchers + +## Overview + +The `configureNativeFetcher()` method provides a **cross-platform** mechanism for passing API credentials from TypeScript/JavaScript code to native fetcher implementations. This guide explains how to use this feature, why it exists, and provides complete examples. + +## Table of Contents + +- [Why `configureNativeFetcher()`?](#why-configurenativefetcher) +- [Architecture](#architecture) +- [TypeScript Interface](#typescript-interface) +- [Native Implementation](#native-implementation) +- [Complete Example](#complete-example) +- [Error Handling](#error-handling) +- [Thread Safety](#thread-safety) +- [Best Practices](#best-practices) +- [Troubleshooting](#troubleshooting) + +--- + +## Why `configureNativeFetcher()`? + +### The Problem + +Native fetchers (implementing `NativeNotificationContentFetcher`) are called from background workers (WorkManager on Android, BGTaskScheduler on iOS). These background workers cannot access: +- JavaScript/TypeScript variables +- Capacitor bridge (unreliable in background) +- React/Vue component state +- WebView storage + +### The Solution + +`configureNativeFetcher()` provides a **direct injection** mechanism: +- βœ… **Cross-platform**: Same TypeScript interface on Android, iOS, and web +- βœ… **Simple**: Only 3 required parameters (apiBaseUrl, activeDid, jwtSecret) +- βœ… **Type-safe**: TypeScript types ensure correct usage +- βœ… **No storage dependency**: Doesn't require SharedPreferences, UserDefaults, etc. + +### Alternatives (and why we don't use them) + +| Approach | Why Not | +|----------|---------| +| Hardcode in native code | ❌ Can't switch environments, credentials in code | +| SharedPreferences/UserDefaults | ❌ Platform-specific, complex sync | +| JS bridge at fetch time | ❌ Unreliable in background workers | +| Capacitor preferences plugin | ❌ Overhead, async issues in background | + +--- + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ TypeScript/JavaScript (Host App) β”‚ +β”‚ β”‚ +β”‚ await DailyNotification.configureNativeFetcher({ β”‚ +β”‚ apiBaseUrl: 'http://10.0.2.2:3000', β”‚ +β”‚ activeDid: 'did:ethr:0x...', β”‚ +β”‚ jwtSecret: 'secret' β”‚ +β”‚ }); β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Capacitor Bridge + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DailyNotificationPlugin (Android/iOS) β”‚ +β”‚ β”‚ +β”‚ @PluginMethod β”‚ +β”‚ configureNativeFetcher(PluginCall call) { β”‚ +β”‚ fetcher.configure(apiBaseUrl, activeDid, jwtSecret); β”‚ +β”‚ } β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Direct method call + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ NativeNotificationContentFetcher (Host App Native Code) β”‚ +β”‚ β”‚ +β”‚ @Override β”‚ +β”‚ void configure(String apiBaseUrl, β”‚ +β”‚ String activeDid, β”‚ +β”‚ String jwtSecret) { β”‚ +β”‚ this.apiBaseUrl = apiBaseUrl; β”‚ +β”‚ this.activeDid = activeDid; β”‚ +β”‚ this.jwtSecret = jwtSecret; β”‚ +β”‚ } β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## TypeScript Interface + +### Method Signature + +```typescript +interface DailyNotificationPlugin { + configureNativeFetcher(options: { + apiBaseUrl: string; // Base URL for API server + activeDid: string; // Active DID for authentication + jwtSecret: string; // JWT secret for signing tokens + }): Promiseθ¬Ή; +} +``` + +### Parameters + +| Parameter | Type | Required | Description | Example | +|-----------|------|----------|-------------|---------| +| `apiBaseUrl` | `string` | βœ… Yes | Base URL for API server. For Android emulator, use `10.0.2.2` to access host machine's `localhost`. | `"http://10.0.2.2:3000"` (Android emulator)
`"http://localhost:3000"` (iOS simulator)
`"https://api.timesafari.com"` (production) | +| `activeDid` | `string` | βœ… Yes | Active DID (Decentralized Identifier) for authentication. Used as JWT issuer/subject. | `"did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F"` | +| `jwtSecret` | `string` | βœ… Yes | JWT secret key for signing authentication tokens. **Keep secure!** | `"test-jwt-secret-for-development"` | + +### Return Value + +- **Success**: `Promise` resolves +- **Failure**: `Promise` rejects with error message + +### Example Usage + +```typescript +import { DailyNotification } from '@capacitor-community/daily-notification'; +import { TEST_USER_ZERO_CONFIG } from './config/test-user-zero'; + +async function initializeNotificationPlugin() { + try { + // Configure native fetcher with User Zero credentials + await DailyNotification.configureNativeFetcher({ + apiBaseUrl: TEST_USER_ZERO_CONFIG.getApiServerUrl(), + activeDid: TEST_USER_ZERO_CONFIG.identity.did, + jwtSecret: 'test-jwt-secret-for-user-zero-development-only' + }); + + console.log('βœ… Native fetcher configured successfully'); + } catch (error) { + console.error('❌ Failed to configure native fetcher:', error); + throw error; + } +} +``` + +--- + +## Native Implementation + +### Java/Kotlin Interface + +The `NativeNotificationContentFetcher` interface includes an optional `configure()` method: + +```java +public interface NativeNotificationContentFetcher { + CompletableFuture> fetchContent(FetchContext context); + + /** + * Optional: Configure the native fetcher with API credentials + */ + default void configure(String apiBaseUrl, String activeDid, String jwtSecret) { + // Default no-op - fetchers that need config can override + } +} +``` + +### Implementation Pattern + +```java +public class MyNativeFetcher implements NativeNotificationContentFetcher { + private static final String TAG = "MyNativeFetcher"; + + // Use volatile for thread-safe access from background workers + private volatile String apiBaseUrl; + private volatile String activeDid; + private volatile String jwtSecret; + + @Override + public void configure(String apiBaseUrl, String activeDid, String jwtSecret) { + this.apiBaseUrl = apiBaseUrl; + this.activeDid = activeDid; + this.jwtSecret = jwtSecret; + + Log.i(TAG, "Fetcher configured - API: " + apiBaseUrl + + ", DID: " + activeDid.substring(0, Math.min(30, activeDid.length())) + "..."); + } + + @Override + public CompletableFuture> fetchContent(FetchContext context) { + return CompletableFuture.supplyAsync(() -> { + // Check if configured + if (apiBaseUrl == null || activeDid == null || jwtSecret == null) { + Log.e(TAG, "Not configured! Call configureNativeFetcher() from TypeScript first."); + return Collections.emptyList(); + } + + // Use configured values to make API calls + // ... + }); + } +} +``` + +### When to Override `configure()` + +**Override if:** +- βœ… Your fetcher needs API credentials from TypeScript +- βœ… Configuration should come from app config (not hardcoded) +- βœ… You want to avoid platform-specific storage + +**Use default no-op if:** +- βœ… Credentials come from platform storage (SharedPreferences, Keychain) +- βœ… You have hardcoded test credentials +- βœ… Configuration is handled internally + +--- + +## Complete Example + +### 1. Register Native Fetcher (Android) + +**File**: `android/app/src/main/java/com/example/app/MyApplication.java` + +```java +package com.example.app; + +import android.app.Application; +import com.timesafari.dailynotification.DailyNotificationPlugin; +import com.timesafari.dailynotification.NativeNotificationContentFetcher; + +public class MyApplication extends Application { + @Override + public void onCreate() { + super.onCreate(); + + // Register native fetcher + NativeNotificationContentFetcher fetcher = new MyNativeFetcher(); + DailyNotificationPlugin.setNativeFetcher(fetcher); + } +} +``` + +**AndroidManifest.xml**: +```xml + + + +``` + +### 2. Configure from TypeScript + +**File**: `src/services/NotificationService.ts` + +```typescript +import { DailyNotification } from '@capacitor-community/daily-notification'; +import { getApiServerUrl, getActiveDid, getJwtSecret } from './config'; + +export async function setupDailyNotifications() { + // Step 1: Configure native fetcher + await DailyNotification.configureNativeFetcher({ + apiBaseUrl: getApiServerUrl(), + activeDid: getActiveDid(), + jwtSecret: getJwtSecret() + }); + + // Step 2: Configure plugin policy + await DailyNotification.setPolicy({ + prefetchWindowMs: 5 * 60 * 1000, // 5 minutes + retryBackoff: { + minMs: 2000, + maxMs: 600000, + factor: 2, + jitterPct: 20 + } + }); + + // Step 3: Schedule notifications + // ... +} +``` + +### 3. Native Fetcher Implementation + +**File**: `android/app/src/main/java/com/example/app/MyNativeFetcher.java` + +```java +package com.example.app; + +import android.util.Log; +import com.timesafari.dailynotification.*; +import java.util.*; + +public class MyNativeFetcher implements NativeNotificationContentFetcher { + private static final String TAG = "MyNativeFetcher"; + private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween"; + + // Thread-safe configuration storage + private volatile String apiBaseUrl; + private volatile String activeDid; + private volatile String jwtSecret; + + @Override + public void configure(String apiBaseUrl, String activeDid, String jwtSecret) { + this.apiBaseUrl = apiBaseUrl; + this.activeDid = activeDid; + this.jwtSecret = jwtSecret; + Log.i(TAG, "Configured with API: " + apiBaseUrl); + } + + @Override + public CompletableFuture> fetchContent(FetchContext context) { + return CompletableFuture.supplyAsync(() -> { + try { + // Validate configuration + if (apiBaseUrl == null || activeDid == null || jwtSecret == null) { + Log.e(TAG, "Not configured! Call configureNativeFetcher() first."); + return Collections.emptyList(); + } + + // Generate JWT token + String jwt = generateJWT(activeDid, jwtSecret); + + // Make API call + String url = apiBaseUrl + reconstituir ENDORSER_ENDPOINT; + // ... HTTP request logic ... + + // Convert response to NotificationContent + List contents = parseResponse(response); + return contents; + + } catch (Exception e) { + Log.e(TAG, "Fetch failed", e); + return Collections.emptyList(); + } + }); + } + + private String generateJWT(String did, String secret) { + // JWT generation logic + // ... + } +} +``` + +--- + +## Error Handling + +### TypeScript Errors + +```typescript +try { + await DailyNotification.configureNativeFetcher({ + apiBaseUrl: 'http://10.0.2.2:3000', + activeDid: 'did:ethr:0x...', + jwtSecret: 'secret' + }); +} catch (error) { + // Possible errors: + // - "Missing required parameters: apiBaseUrl, activeDid, and jwtSecret are required" + // - "No native fetcher registered. Register one in Application.onCreate() before configuring." + // - "Failed to configure native fetcher: " + console.error('Configuration failed:', error); +} +``` + +### Native Fetcher Validation + +```java +@Override +public CompletableFuture> fetchContent(FetchContext context) { + return CompletableFuture.supplyAsync(() -> { + // Always check configuration before use + if (apiBaseUrl == null || activeDid == null || jwtSecret == null) { + Log.e(TAG, "Not configured! Returning empty list."); + return Collections.emptyList(); + } + + // Proceed with fetch... + }); +} +``` + +--- + +## Thread Safety + +### Background Worker Context + +Native fetchers are called from **background worker threads** (WorkManager, BGTaskScheduler). Configuration may be set from the **main thread** (TypeScript call). + +### Volatile Fields + +Use `volatile` for configuration fields to ensure: +- βœ… Changes are immediately visible to all threads +- βœ… No caching of stale values +- βœ… Thread-safe access without synchronization + +```java +// βœ… Correct: Volatile fields +private volatile String apiBaseUrl; +private volatile String activeDid; +private volatile String jwtSecret; + +// ❌ Incorrect: Non-volatile fields (may see stale values) +private String apiBaseUrl; +private String activeDid; +private String jwtSecret; +``` + +### Reconfiguration + +Configuration can be updated at any time: + +```typescript +// Update configuration when user switches accounts +await DailyNotification.configureNativeFetcher({ + apiBaseUrl: newApiUrl, + activeDid: newActiveDid, + jwtSecret: newJwtSecret +}); +``` + +Subsequent `fetchContent()` calls will use the new configuration immediately. + +--- + +## Best Practices + +### 1. Configure Early + +Call `configureNativeFetcher()` as soon as credentials are available: + +```typescript +// βœ… Good: Configure immediately after app startup +await appInit(); +await DailyNotification.configureNativeFetcher(config); + +// ❌ Bad: Wait until first fetch +// (background worker might trigger before configuration) +``` + +### 2. Validate in Native Code + +Always validate configuration before use: + +```java +@Override +public CompletableFuture> fetchContent(FetchContext context) { + if (apiBaseUrl == null || activeDid == null || jwtSecret == null) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + // ... +} +``` + +### 3. Log Configuration (Sanitized) + +Log configuration for debugging, but sanitize sensitive data: + +```java +@Override +public void configure(String apiBaseUrl, String activeDid, String jwtSecret) { + this.apiBaseUrl = apiBaseUrl; + this.activeDid = activeDid; + this.jwtSecret = jwtSecret; + + Log.i(TAG, "Configured - API: " + apiBaseUrl + + ", DID: " + activeDid.substring(0, Math.min(30, activeDid.length())) + "..."); + // Don't log jwtSecret! +} +``` + +### 4. Handle Reconfiguration + +If configuration changes (e.g., user switches accounts), update gracefully: + +```java +@Override +public void configure(String apiBaseUrl, String activeDid, String jwtSecret) { + // Clear any cached state if DID changes + if (this.activeDid != null && !this.activeDid.equals(activeDid)) { + Log.i(TAG, "ActiveDid changed - clearing cache"); + clearCache(); + } + + this.apiBaseUrl = apiBaseUrl; + this.activeDid = activeDid; + this.jwtSecret = jwtSecret; +} +``` + +### 5. Secure JWT Secrets + +In production, consider: +- Using secure storage (Android Keystore, iOS Keychain) +- Deriving secret from device-specific keys +- Using short-lived secrets with rotation + +```typescript +// Example: Get secret from secure storage +const jwtSecret = await SecureStorage.get({ key: 'jwt_secret' }); +await DailyNotification.configureNativeFetcher({ + apiBaseUrl: apiUrl, + activeDid: activeDid, + jwtSecret: jwtSecret.value +}); +``` + +--- + +## Troubleshooting + +### Error: "No native fetcher registered" + +**Cause**: Native fetcher not registered in `Application.onCreate()` + +**Solution**: +```java +// In MyApplication.java +@Override +public void onCreate() { + super.onCreate(); + DailyNotificationPlugin.setNativeFetcher(new MyNativeFetcher()); +} +``` + +### Error: "Missing required parameters" + +**Cause**: One or more parameters not provided + +**Solution**: Ensure all three parameters are provided: +```typescript +await DailyNotification.configureNativeFetcher({ + apiBaseUrl: 'http://10.0.2.2:3000', // βœ… Required + activeDid: 'did:ethr:0x...', // βœ… Required + jwtSecret: 'secret' // βœ… Required +}); +``` + +### Fetcher Not Using Configuration + +**Cause**: Native fetcher not checking configuration before use + +**Solution**: Validate in `fetchContent()`: +```java +if (apiBaseUrl == null || activeDid == null || jwtSecret == null) { + Log.e(TAG, "Not configured!"); + return Collections.emptyList(); +} +``` + +### Configuration Lost After App Restart + +**Cause**: Configuration is in-memory only (by design) + +**Solution**: Reconfigure after app startup: +```typescript +// In app initialization +platform.whenReady(() => { + setupDailyNotifications(); // Calls configureNativeFetcher() +}); +``` + +--- + +## Related Documentation + +- **[Integration Point Refactor Guide](./INTEGRATION_REFACTOR_QUICK_START.md)** - Overall architecture +- **[API Reference](./API.md)** - Complete plugin API +- **[Native Fetcher Examples](../examples/native-fetcher-android.kt)** - Kotlin implementation examples +- **[Test Implementation](../test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java)** - Complete reference implementation + +--- + +**Last Updated**: 2025-01-28 +**Version**: 1.0.0 + diff --git a/src/definitions.ts b/src/definitions.ts index 10173d0..921f8b5 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -314,6 +314,71 @@ export interface DailyNotificationPlugin { // Configuration methods configure(options: ConfigureOptions): Promise; + /** + * Configure native fetcher with API credentials (cross-platform) + * + * This method provides a cross-platform mechanism for passing API credentials + * from TypeScript/JavaScript code to native fetcher implementations. The + * configuration is passed directly without using platform-specific storage + * mechanisms, keeping the interface consistent across Android, iOS, and web. + * + * **Why this exists:** + * - Native fetchers run in background workers (WorkManager/BGTaskScheduler) + * - Background workers cannot access JavaScript variables or Capacitor bridge + * - This method provides direct injection without storage dependencies + * + * **When to call:** + * - After app startup, once API credentials are available + * - After user login/authentication, when activeDid changes + * - When API server URL changes (dev/staging/production) + * + * **Prerequisites:** + * - Native fetcher must be registered in `Application.onCreate()` (Android) + * or `AppDelegate.didFinishLaunching()` (iOS) BEFORE calling this method + * - Should be called before any background fetches occur + * + * **Thread Safety:** + * - Safe to call from any thread (main thread or background) + * - Configuration changes take effect immediately for subsequent fetches + * - Native implementations should use `volatile` fields for thread safety + ΰΉ€ΰΈ£ΰΉ‡ * + * **Example:** + * ```typescript + * import { DailyNotification } from '@capacitor-community/daily-notification'; + * + * await DailyNotification.configureNativeFetcher({ + * apiBaseUrl: 'http://10.0.2.2:3000', // Android emulator β†’ host localhost + * activeDid: 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F', + * jwtSecret: 'test-jwt-secret-for-development' + * }); + * ``` + * + * **Error Handling:** + * - Rejects if required parameters are missing + * - Rejects if no native fetcher is registered + * - Rejects if native fetcher's `configure()` throws exception + * + * @param options Configuration options: + * - `apiBaseUrl` (required): Base URL for API server. + * - Android emulator: `"http://10.0.2.2:3000"` (maps to host localhost:3000) + * - iOS simulator: `"http://localhost:3000"` + * - Production: `"https://api.timesafari.com"` + * - `activeDid` (required): Active DID for authentication. + * Format: `"did:ethr:0x..."`. Used as JWT issuer/subject. + * - `jwtSecret` (required): JWT secret for signing tokens. + * **Keep secure in production!** Consider using secure storage. + * + * @throws {Error} If configuration fails (missing params, no fetcher registered, etc.) + * + * @see {@link https://github.com/timesafari/daily-notification-plugin/blob/main/docs/NATIVE_FETCHER_CONFIGURATION.md | Native Fetcher Configuration Guide} + * for complete documentation and examples + */ + configureNativeFetcher(options: { + apiBaseUrl: string; + activeDid: string; + jwtSecret: string; + }): Promise; + // Rolling window management maintainRollingWindow(): Promise; getRollingWindowStats(): Promise<{ diff --git a/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java b/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java index c6ec77f..0ef5179 100644 --- a/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java +++ b/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java @@ -1,8 +1,8 @@ /** * TestNativeFetcher.java * - * Simple test implementation of NativeNotificationContentFetcher for the test app. - * Returns mock notification content for testing purposes. + * Test implementation of NativeNotificationContentFetcher for the test app. + * Fetches real notification content from the endorser API endpoint. * * @author Matthew Raymer * @version 1.0.0 @@ -15,16 +15,30 @@ import androidx.annotation.NonNull; import com.timesafari.dailynotification.FetchContext; import com.timesafari.dailynotification.NativeNotificationContentFetcher; import com.timesafari.dailynotification.NotificationContent; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.ArrayList; +import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; /** * Test implementation of native content fetcher * - * Returns mock notification content for testing. In production, this would - * fetch from TimeSafari API or other data sources. + * Fetches real notification content from the endorser API endpoint. + * Uses http://10.0.2.2:3000 for Android emulator (maps to host localhost:3000). */ public class TestNativeFetcher implements NativeNotificationContentFetcher { @@ -40,46 +54,246 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher { return CompletableFuture.supplyAsync(() -> { try { - // Simulate network delay - Thread.sleep(100); + // Check if configured + if (apiBaseUrl == null || activeDid == null || jwtSecret == null) { + Log.e(TAG, "TestNativeFetcher: Not configured. Call configureNativeFetcher() from TypeScript first."); + return Collections.emptyList(); + } - List contents = new ArrayList<>(); + Log.i(TAG, "TestNativeFetcher: Starting fetch from " + apiBaseUrl + ENDORSER_ENDPOINT); - // Create a test notification - NotificationContent testContent = new NotificationContent(); - testContent.setId("test_notification_" + System.currentTimeMillis()); - testContent.setTitle("Test Notification from Native Fetcher"); - testContent.setBody("This is a test notification from the native fetcher SPI. " + - "Trigger: " + context.trigger + - (context.scheduledTime != null ? - ", Scheduled: " + new java.util.Date(context.scheduledTime) : "")); + // Generate JWT token for authentication + String jwtToken = generateJWTToken(); - // Use scheduled time from context, or default to 1 hour from now - long scheduledTimeMs = context.scheduledTime != null ? - context.scheduledTime : (System.currentTimeMillis() + 3600000); - testContent.setScheduledTime(scheduledTimeMs); + // Build request URL + String urlString = apiBaseUrl + ENDORSER_ENDPOINT; + URL url = new URL(urlString); - // fetchTime is set automatically by NotificationContent constructor (as fetchedAt) - testContent.setPriority("default"); - testContent.setSound(true); + // Create HTTP connection + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + jwtToken); + connection.setDoOutput(true); - contents.add(testContent); + // Build request body + Map requestBody = new HashMap<>(); + requestBody.put("planIds", getStarredPlanIds()); + requestBody.put("afterId", getLastAcknowledgedJwtId()); - Log.i(TAG, "TestNativeFetcher: Returning " + contents.size() + - " notification(s)"); + String jsonBody = gson.toJson(requestBody); + Log.d(TAG, "TestNativeFetcher: Request body: " + jsonBody); - return contents; + // Write request body + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } - } catch (InterruptedException e) { - Log.e(TAG, "TestNativeFetcher: Interrupted during fetch", e); - Thread.currentThread().interrupt(); - return Collections.emptyList(); + // Execute request + int responseCode = connection.getResponseCode(); + Log.d(TAG, "TestNativeFetcher: HTTP response code: " + responseCode); + + if (responseCode == 200) { + // Read response + BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)); + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + + String responseBody = response.toString(); + Log.d(TAG, "TestNativeFetcher: Response body length: " + responseBody.length()); + + // Parse response and convert to NotificationContent + List contents = parseApiResponse(responseBody, context); + + Log.i(TAG, "TestNativeFetcher: Successfully fetched " + contents.size() + + " notification(s)"); + + return contents; + + } else { + // Read error response + BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8)); + StringBuilder errorResponse = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + errorResponse.append(line); + } + reader.close(); + + Log.e(TAG, "TestNativeFetcher: API error " + responseCode + + ": " + errorResponse.toString()); + + // Return empty list on error (fallback will be handled by worker) + return Collections.emptyList(); + } } catch (Exception e) { Log.e(TAG, "TestNativeFetcher: Error during fetch", e); + // Return empty list on error (fallback will be handled by worker) return Collections.emptyList(); } }); } + + /** + * Generate JWT token for API authentication + * Simplified implementation matching test-user-zero config + */ + private String generateJWTToken() { + try { + long nowEpoch = System.currentTimeMillis() / 1000; + long expEpoch = nowEpoch + (JWT_EXPIRATION_MINUTES * 60); + + // Create JWT header + Map header = new HashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + + // Create JWT payload + Map payload = new HashMap<>(); + payload.put("exp", expEpoch); + payload.put("iat", nowEpoch); + payload.put("iss", activeDid); + payload.put("aud", "timesafari.notifications"); + payload.put("sub", activeDid); + + // Encode header and payload + String headerJson = gson.toJson(header); + String payloadJson = gson.toJson(payload); + + String headerB64 = base64UrlEncode(headerJson.getBytes(StandardCharsets.UTF_8)); + String payloadB64 = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8)); + + // Create signature + String unsignedToken = headerB64 + "." + payloadB64; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((unsignedToken + ":" + activeDid).getBytes(StandardCharsets.UTF_8)); + String signature = base64UrlEncode(hash); + + String jwt = unsignedToken + "." + signature; + Log.d(TAG, "TestNativeFetcher: Generated JWT token"); + + return jwt; + + } catch (Exception e) { + Log.e(TAG, "TestNativeFetcher: Error generating JWT", e); + throw new RuntimeException("Failed to generate JWT", e); + } + } + + /** + * Base64 URL encoding (without padding) + */ + private String base64UrlEncode(byte[] data) { + String encoded = Base64.getEncoder().encodeToString(data); + return encoded.replace("+", "-").replace("/", "_").replace("=", ""); + } + + /** + * Get starred plan IDs (from test-user-zero config or SharedPreferences) + */ + private List getStarredPlanIds() { + // TODO: Load from SharedPreferences or config + // For now, return empty list (API will return all relevant plans) + return new ArrayList<>(); + } + + /** + * Get last acknowledged JWT ID (for pagination) + */ + private String getLastAcknowledgedJwtId() { + // TODO: Load from SharedPreferences + // For now, return null (fetch all updates) + return null; + } + + /** + * Parse API response and convert to NotificationContent list + */ + private List parseApiResponse(String responseBody, FetchContext context) { + List contents = new ArrayList<>(); + + try { + JsonParser parser = new JsonParser(); + JsonObject root = parser.parse(responseBody).getAsJsonObject(); + + // Parse response structure (matches PlansLastUpdatedResponse) + JsonArray dataArray = root.getAsJsonArray("data"); + if (dataArray != null) { + for (int i = 0; i < dataArray.size(); i++) { + JsonObject item = dataArray.get(i).getAsJsonObject(); + + NotificationContent content = new NotificationContent(); + + // Extract data from API response + String planId = item.has("planId") ? item.get("planId").getAsString() : null; + String jwtId = item.has("jwtId") ? item.get("jwtId").getAsString() : null; + + // Create notification ID + String notificationId = "endorser_" + (jwtId != null ? jwtId : + System.currentTimeMillis() + "_" + i); + content.setId(notificationId); + + // Create notification title + String title = "Project Update"; + if (planId != null) { + title = "Update: " + planId.substring(Math.max(0, planId.length() - 8)); + } + content.setTitle(title); + + // Create notification body + StringBuilder body = new StringBuilder(); + if (planId != null) { + body.append("Plan ").append(planId.substring(Math.max(0, planId.length() - 12))).append(" has been updated."); + } else { + body.append("A project you follow has been updated."); + } + content.setBody(body.toString()); + + // Use scheduled time from context, or default to 1 hour from now + long scheduledTimeMs = context.scheduledTime != null ? + context.scheduledTime : (System.currentTimeMillis() + 3600000); + content.setScheduledTime(scheduledTimeMs); + + // Set notification properties + content.setPriority("default"); + content.setSound(true); + + contents.add(content); + } + } + + // If no data items, create a default notification + if (contents.isEmpty()) { + NotificationContent defaultContent = new NotificationContent(); + defaultContent.setId("endorser_no_updates_" + System.currentTimeMillis()); + defaultContent.setTitle("No Project Updates"); + defaultContent.setBody("No updates found in your starred projects."); + + long scheduledTimeMs = context.scheduledTime != null ? + context.scheduledTime : (System.currentTimeMillis() + 3600000); + defaultContent.setScheduledTime(scheduledTimeMs); + defaultContent.setPriority("default"); + defaultContent.setSound(true); + + contents.add(defaultContent); + } + + } catch (Exception e) { + Log.e(TAG, "TestNativeFetcher: Error parsing API response", e); + // Return empty list on parse error + } + + return contents; + } }