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