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:
Matthew Raymer
2025-10-30 10:03:47 +00:00
parent 59cd975c24
commit c1cc8802f6
5 changed files with 954 additions and 30 deletions

View File

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