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.
This commit is contained in:
@@ -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.
|
||||
*
|
||||
* <p><b>Configuration:</b></p>
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>For an example that accepts configuration from TypeScript, see
|
||||
* {@code TestNativeFetcher} in the test app.</p>
|
||||
*/
|
||||
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<List<NotificationContent>> fetchContent(
|
||||
|
||||
@@ -82,5 +82,62 @@ public interface NativeNotificationContentFetcher {
|
||||
*/
|
||||
@NonNull
|
||||
CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext context);
|
||||
|
||||
/**
|
||||
* Optional: Configure the native fetcher with API credentials and settings
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p><b>When to implement:</b></p>
|
||||
* <ul>
|
||||
* <li>Your fetcher needs API credentials (URL, authentication tokens, etc.)</li>
|
||||
* <li>Configuration should come from TypeScript/JavaScript code (e.g., from app config)</li>
|
||||
* <li>You want to avoid hardcoding credentials in native code</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>When to skip (use default no-op):</b></p>
|
||||
* <ul>
|
||||
* <li>Your fetcher gets credentials from platform-specific storage (SharedPreferences, Keychain, etc.)</li>
|
||||
* <li>Your fetcher has hardcoded test credentials</li>
|
||||
* <li>Configuration is handled internally and doesn't need external input</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Thread Safety:</b> This method may be called from any thread. Implementations
|
||||
* must be thread-safe if storing configuration in instance variables.</p>
|
||||
*
|
||||
* <p><b>Implementation Pattern:</b></p>
|
||||
* <pre>{@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);
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* @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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
576
docs/NATIVE_FETCHER_CONFIGURATION.md
Normal file
576
docs/NATIVE_FETCHER_CONFIGURATION.md
Normal file
@@ -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謹<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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)<br>`"http://localhost:3000"` (iOS simulator)<br>`"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<void>` resolves
|
||||
- **Failure**: `Promise<void>` 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<List<NotificationContent>> 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<List<NotificationContent>> 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
|
||||
<application android:name=".MyApplication">
|
||||
<!-- ... -->
|
||||
</application>
|
||||
```
|
||||
|
||||
### 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<List<NotificationContent>> 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<NotificationContent> 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: <native exception message>"
|
||||
console.error('Configuration failed:', error);
|
||||
}
|
||||
```
|
||||
|
||||
### Native Fetcher Validation
|
||||
|
||||
```java
|
||||
@Override
|
||||
public CompletableFuture<List<NotificationContent>> 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<List<NotificationContent>> 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
|
||||
|
||||
@@ -314,6 +314,71 @@ export interface DailyNotificationPlugin {
|
||||
// Configuration methods
|
||||
configure(options: ConfigureOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
// Rolling window management
|
||||
maintainRollingWindow(): Promise<void>;
|
||||
getRollingWindowStats(): Promise<{
|
||||
|
||||
@@ -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<NotificationContent> 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<String, Object> 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<NotificationContent> 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<String, Object> header = new HashMap<>();
|
||||
header.put("alg", "HS256");
|
||||
header.put("typ", "JWT");
|
||||
|
||||
// Create JWT payload
|
||||
Map<String, Object> 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<String> 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<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
|
||||
List<NotificationContent> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user