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