chore: commit to move to laptop

This commit is contained in:
Matthew Raymer
2025-10-31 09:56:23 +00:00
parent c1cc8802f6
commit 01b7dae5df
26 changed files with 2243 additions and 145 deletions

View File

@@ -7,6 +7,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">

View File

@@ -11,6 +11,7 @@
package com.timesafari.dailynotification.test;
import android.app.Application;
import android.content.Context;
import android.util.Log;
import com.timesafari.dailynotification.DailyNotificationPlugin;
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
@@ -28,9 +29,10 @@ public class TestApplication extends Application {
Log.i(TAG, "Initializing Daily Notification Plugin test app");
// Register test native fetcher
// Register test native fetcher with application context
Context context = getApplicationContext();
NativeNotificationContentFetcher testFetcher =
new com.timesafari.dailynotification.test.TestNativeFetcher();
new com.timesafari.dailynotification.test.TestNativeFetcher(context);
DailyNotificationPlugin.setNativeFetcher(testFetcher);
Log.i(TAG, "Test native fetcher registered: " + testFetcher.getClass().getName());

View File

@@ -10,6 +10,8 @@
package com.timesafari.dailynotification.test;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import com.timesafari.dailynotification.FetchContext;
@@ -25,9 +27,7 @@ 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;
@@ -43,6 +43,59 @@ import java.util.concurrent.CompletableFuture;
public class TestNativeFetcher implements NativeNotificationContentFetcher {
private static final String TAG = "TestNativeFetcher";
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
private static final int CONNECT_TIMEOUT_MS = 10000; // 10 seconds
private static final int READ_TIMEOUT_MS = 15000; // 15 seconds
private static final int MAX_RETRIES = 3; // Maximum number of retry attempts
private static final int RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
// SharedPreferences constants
private static final String PREFS_NAME = "DailyNotificationPrefs";
private static final String KEY_STARRED_PLAN_IDS = "starred_plan_ids";
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id";
private final Gson gson = new Gson();
private final Context appContext;
private SharedPreferences prefs;
// Volatile fields for configuration, set via configure() method
private volatile String apiBaseUrl;
private volatile String activeDid;
private volatile String jwtToken; // Pre-generated JWT token from TypeScript (ES256K signed)
/**
* Constructor
*
* @param context Application context for SharedPreferences access
*/
public TestNativeFetcher(Context context) {
this.appContext = context.getApplicationContext();
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
Log.d(TAG, "TestNativeFetcher: Initialized with context");
}
/**
* Configure the native fetcher with API credentials
*
* Called by the plugin when configureNativeFetcher() is invoked from TypeScript.
* This method stores the configuration for use in background fetches.
*
* <p><b>Architecture Note:</b> The JWT token is pre-generated in TypeScript using
* TimeSafari's {@code createEndorserJwtForKey()} function (ES256K DID-based signing).
* The native fetcher just uses the token directly - no JWT generation needed.</p>
*
* @param apiBaseUrl Base URL for API server (e.g., "http://10.0.2.2:3000")
* @param activeDid Active DID for authentication (e.g., "did:ethr:0x...")
* @param jwtToken Pre-generated JWT token (ES256K signed) from TypeScript
*/
@Override
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken;
Log.i(TAG, "TestNativeFetcher: Configured with API: " + apiBaseUrl +
", ActiveDID: " + (activeDid != null ? activeDid.substring(0, Math.min(20, activeDid.length())) + "..." : "null"));
}
@Override
@NonNull
@@ -52,19 +105,30 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
Log.d(TAG, "TestNativeFetcher: Fetch triggered - trigger=" + context.trigger +
", scheduledTime=" + context.scheduledTime + ", fetchTime=" + context.fetchTime);
// Start with retry count 0
return fetchContentWithRetry(context, 0);
}
/**
* Fetch content with retry logic for transient errors
*
* @param context Fetch context
* @param retryCount Current retry attempt (0 for first attempt)
* @return Future with notification contents or empty list on failure
*/
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
@NonNull FetchContext context, int retryCount) {
return CompletableFuture.supplyAsync(() -> {
try {
// Check if configured
if (apiBaseUrl == null || activeDid == null || jwtSecret == null) {
if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
Log.e(TAG, "TestNativeFetcher: Not configured. Call configureNativeFetcher() from TypeScript first.");
return Collections.emptyList();
}
Log.i(TAG, "TestNativeFetcher: Starting fetch from " + apiBaseUrl + ENDORSER_ENDPOINT);
// Generate JWT token for authentication
String jwtToken = generateJWTToken();
// Build request URL
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
URL url = new URL(urlString);
@@ -113,6 +177,12 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
// Parse response and convert to NotificationContent
List<NotificationContent> contents = parseApiResponse(responseBody, context);
// Update last acknowledged JWT ID from the response (for pagination)
if (!contents.isEmpty()) {
// Get the last JWT ID from the parsed response (stored during parsing)
updateLastAckedJwtIdFromResponse(contents, responseBody);
}
Log.i(TAG, "TestNativeFetcher: Successfully fetched " + contents.size() +
" notification(s)");
@@ -120,100 +190,227 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
} 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);
String errorMessage = "Unknown error";
try {
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();
errorMessage = errorResponse.toString();
} catch (Exception e) {
Log.w(TAG, "TestNativeFetcher: Could not read error stream", e);
}
reader.close();
Log.e(TAG, "TestNativeFetcher: API error " + responseCode +
": " + errorResponse.toString());
Log.e(TAG, "TestNativeFetcher: API error " + responseCode + ": " + errorMessage);
// Handle retryable errors (5xx server errors, network timeouts)
if (shouldRetry(responseCode, retryCount)) {
long delayMs = RETRY_DELAY_MS * (1 << retryCount); // Exponential backoff
Log.w(TAG, "TestNativeFetcher: Retryable error, retrying in " + delayMs + "ms " +
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Log.e(TAG, "TestNativeFetcher: Retry delay interrupted", e);
return Collections.emptyList();
}
// Recursive retry
return fetchContentWithRetry(context, retryCount + 1).join();
}
// Non-retryable errors (4xx client errors, max retries reached)
if (responseCode >= 400 && responseCode < 500) {
Log.e(TAG, "TestNativeFetcher: Non-retryable client error " + responseCode);
} else if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "TestNativeFetcher: Max retries (" + MAX_RETRIES + ") reached");
}
// Return empty list on error (fallback will be handled by worker)
return Collections.emptyList();
}
} catch (java.net.SocketTimeoutException | java.net.UnknownHostException e) {
// Network errors are retryable
Log.w(TAG, "TestNativeFetcher: Network error during fetch", e);
if (shouldRetry(0, retryCount)) { // Use 0 as response code for network errors
long delayMs = RETRY_DELAY_MS * (1 << retryCount);
Log.w(TAG, "TestNativeFetcher: Retrying after network error in " + delayMs + "ms " +
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
try {
Thread.sleep(delayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
Log.e(TAG, "TestNativeFetcher: Retry delay interrupted", ie);
return Collections.emptyList();
}
return fetchContentWithRetry(context, retryCount + 1).join();
}
Log.e(TAG, "TestNativeFetcher: Max retries reached for network error");
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)
// Non-retryable errors (parsing, configuration, etc.)
return Collections.emptyList();
}
});
}
/**
* Generate JWT token for API authentication
* Simplified implementation matching test-user-zero config
* Determine if an error should be retried
*
* @param responseCode HTTP response code (0 for network errors)
* @param retryCount Current retry attempt count
* @return true if error is retryable and retry count not exceeded
*/
private String generateJWTToken() {
private boolean shouldRetry(int responseCode, int retryCount) {
if (retryCount >= MAX_RETRIES) {
return false; // Max retries exceeded
}
// Retry on network errors (responseCode 0) or server errors (5xx)
// Don't retry on client errors (4xx) as they indicate permanent issues
if (responseCode == 0) {
return true; // Network error (timeout, unknown host, etc.)
}
if (responseCode >= 500 && responseCode < 600) {
return true; // Server error (retryable)
}
// Some 4xx errors might be retryable (e.g., 429 Too Many Requests)
if (responseCode == 429) {
return true; // Rate limit - retry with backoff
}
return false; // Other client errors (401, 403, 404, etc.) are not retryable
}
/**
* Get starred plan IDs from SharedPreferences
*
* @return List of starred plan IDs, empty list if none stored
*/
private List<String> getStarredPlanIds() {
try {
long nowEpoch = System.currentTimeMillis() / 1000;
long expEpoch = nowEpoch + (JWT_EXPIRATION_MINUTES * 60);
String idsJson = prefs.getString(KEY_STARRED_PLAN_IDS, "[]");
if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) {
return new ArrayList<>();
}
// Create JWT header
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
// Parse JSON array
JsonParser parser = new JsonParser();
JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray();
List<String> planIds = new ArrayList<>();
// 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);
for (int i = 0; i < jsonArray.size(); i++) {
planIds.add(jsonArray.get(i).getAsString());
}
// 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;
Log.d(TAG, "TestNativeFetcher: Loaded " + planIds.size() + " starred plan IDs");
return planIds;
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error generating JWT", e);
throw new RuntimeException("Failed to generate JWT", e);
Log.e(TAG, "TestNativeFetcher: Error loading starred plan IDs", e);
return new ArrayList<>();
}
}
/**
* 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)
* Get last acknowledged JWT ID from SharedPreferences (for pagination)
*
* @return Last acknowledged JWT ID, or null if none stored
*/
private String getLastAcknowledgedJwtId() {
// TODO: Load from SharedPreferences
// For now, return null (fetch all updates)
return null;
try {
String jwtId = prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
if (jwtId != null) {
Log.d(TAG, "TestNativeFetcher: Loaded last acknowledged JWT ID");
}
return jwtId;
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error loading last acknowledged JWT ID", e);
return null;
}
}
/**
* Update starred plan IDs in SharedPreferences
*
* @param planIds List of plan IDs to store
*/
public void updateStarredPlanIds(List<String> planIds) {
try {
String idsJson = gson.toJson(planIds);
prefs.edit().putString(KEY_STARRED_PLAN_IDS, idsJson).apply();
Log.i(TAG, "TestNativeFetcher: Updated starred plan IDs: " + planIds.size() + " plans");
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error updating starred plan IDs", e);
}
}
/**
* Update last acknowledged JWT ID in SharedPreferences
*
* @param jwtId JWT ID to store as last acknowledged
*/
public void updateLastAckedJwtId(String jwtId) {
try {
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
Log.d(TAG, "TestNativeFetcher: Updated last acknowledged JWT ID");
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error updating last acknowledged JWT ID", e);
}
}
/**
* Update last acknowledged JWT ID from the API response
* Uses the last JWT ID from the data array for pagination
*
* @param contents Parsed notification contents (may contain JWT IDs)
* @param responseBody Original response body for parsing
*/
private void updateLastAckedJwtIdFromResponse(List<NotificationContent> contents, String responseBody) {
try {
JsonParser parser = new JsonParser();
JsonObject root = parser.parse(responseBody).getAsJsonObject();
JsonArray dataArray = root.getAsJsonArray("data");
if (dataArray != null && dataArray.size() > 0) {
// Get the last item's JWT ID (most recent)
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
// Try to get JWT ID from different possible locations in response structure
String jwtId = null;
if (lastItem.has("jwtId")) {
jwtId = lastItem.get("jwtId").getAsString();
} else if (lastItem.has("plan")) {
JsonObject plan = lastItem.getAsJsonObject("plan");
if (plan.has("jwtId")) {
jwtId = plan.get("jwtId").getAsString();
}
}
if (jwtId != null && !jwtId.isEmpty()) {
updateLastAckedJwtId(jwtId);
Log.d(TAG, "TestNativeFetcher: Updated last acknowledged JWT ID: " +
jwtId.substring(0, Math.min(20, jwtId.length())) + "...");
}
}
} catch (Exception e) {
Log.w(TAG, "TestNativeFetcher: Could not extract JWT ID from response for pagination", e);
}
}
/**
@@ -235,8 +432,27 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
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;
// Support both flat structure (jwtId, planId) and nested (plan.jwtId, plan.handleId)
String planId = null;
String jwtId = null;
if (item.has("planId")) {
planId = item.get("planId").getAsString();
} else if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("handleId")) {
planId = plan.get("handleId").getAsString();
}
}
if (item.has("jwtId")) {
jwtId = item.get("jwtId").getAsString();
} else if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("jwtId")) {
jwtId = plan.get("jwtId").getAsString();
}
}
// Create notification ID
String notificationId = "endorser_" + (jwtId != null ? jwtId :

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Network Security Configuration for Daily Notification Test App
Allows cleartext HTTP traffic to localhost (10.0.2.2) for Android emulator testing.
This is required for connecting to http://10.0.2.2:3000 from the Android emulator
(which maps to host machine's localhost:3000).
Author: Matthew Raymer
Date: 2025-10-31
-->
<network-security-config>
<!--
Allow cleartext traffic to Android emulator's localhost alias (10.0.2.2).
This is safe because:
1. Only accessible from Android emulator
2. Only used for development/testing
3. Does not affect production builds (production should use HTTPS)
-->
<domain-config cleartextTrafficPermitted="true">
<!-- Android emulator's special IP for host machine's localhost -->
<domain includeSubdomains="false">10.0.2.2</domain>
<!-- Also allow direct localhost access (for some emulator configurations) -->
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
<!--
Production domains should use HTTPS only (default behavior).
This configuration only adds exceptions for localhost development.
-->
</network-security-config>

View File

@@ -3,4 +3,6 @@ include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':timesafari-daily-notification-plugin'
// NOTE: Plugin module is in android/plugin/ subdirectory, not android root
// This file is auto-generated by Capacitor, but must be manually corrected for this plugin structure
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android/plugin')