Browse Source

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.
master
Matthew Raymer 2 days ago
parent
commit
c1cc8802f6
  1. 12
      android/app/src/main/java/com/timesafari/dailynotification/DemoNativeFetcher.java
  2. 57
      android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java
  3. 576
      docs/NATIVE_FETCHER_CONFIGURATION.md
  4. 65
      src/definitions.ts
  5. 274
      test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java

12
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.
*
* <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(

57
android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java

@ -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

@ -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

65
src/definitions.ts

@ -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<{

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

Loading…
Cancel
Save