diff --git a/src/android/DailyNotificationJWTManager.java b/src/android/DailyNotificationJWTManager.java new file mode 100644 index 0000000..390ff10 --- /dev/null +++ b/src/android/DailyNotificationJWTManager.java @@ -0,0 +1,407 @@ +/** + * DailyNotificationJWTManager.java + * + * Android JWT Manager for TimeSafari authentication enhancement + * Extends existing ETagManager infrastructure with DID-based JWT authentication + * + * @author Matthew Raymer + * @version 1.0.0 + * @created 2025-10-03 06:53:30 UTC + */ + +package com.timesafari.dailynotification; + +import android.util.Log; +import android.content.Context; + +import java.net.HttpURLConnection; +import java.util.HashMap; +import java.util.Map; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; + +/** + * Manages JWT authentication for TimeSafari integration + * + * This class extends the existing ETagManager infrastructure by adding: + * - DID-based JWT token generation + * - Automatic JWT header injection into HTTP requests + * - JWT token expiration management + * - Integration with existing DailyNotificationETagManager + * + * Phase 1 Implementation: Extends existing DailyNotificationETagManager.java + */ +public class DailyNotificationJWTManager { + + // MARK: - Constants + + private static final String TAG = "DailyNotificationJWTManager"; + + // JWT Headers + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + + // JWT Configuration + private static final int DEFAULT_JWT_EXPIRATION_SECONDS = 60; + + // JWT Algorithm (simplified for Phase 1) + private static final String ALGORITHM = "HS256"; + + // MARK: - Properties + + private final DailyNotificationStorage storage; + private final DailyNotificationETagManager eTagManager; + + // Current authentication state + private String currentActiveDid; + private String currentJWTToken; + private long jwtExpirationTime; + + // Configuration + private int jwtExpirationSeconds; + + // MARK: - Initialization + + /** + * Constructor + * + * @param storage Storage instance for persistence + * @param eTagManager ETagManager instance for HTTP enhancements + */ + public DailyNotificationJWTManager(DailyNotificationStorage storage, DailyNotificationETagManager eTagManager) { + this.storage = storage; + this.eTagManager = eTagManager; + this.jwtExpirationSeconds = DEFAULT_JWT_EXPIRATION_SECONDS; + + Log.d(TAG, "JWTManager initialized with ETagManager integration"); + } + + // MARK: - ActiveDid Management + + /** + * Set the active DID for authentication + * + * @param activeDid The DID to use for JWT generation + */ + public void setActiveDid(String activeDid) { + setActiveDid(activeDid, DEFAULT_JWT_EXPIRATION_SECONDS); + } + + /** + * Set the active DID for authentication with custom expiration + * + * @param activeDid The DID to use for JWT generation + * @param expirationSeconds JWT expiration time in seconds + */ + public void setActiveDid(String activeDid, int expirationSeconds) { + try { + Log.d(TAG, "Setting activeDid: " + activeDid + " with " + expirationSeconds + "s expiration"); + + this.currentActiveDid = activeDid; + this.jwtExpirationSeconds = expirationSeconds; + + // Generate new JWT token immediately + generateAndCacheJWT(); + + Log.i(TAG, "ActiveDid set successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error setting activeDid", e); + throw new RuntimeException("Failed to set activeDid", e); + } + } + + /** + * Get the current active DID + * + * @return Current active DID or null if not set + */ + public String getCurrentActiveDid() { + return currentActiveDid; + } + + /** + * Check if we have a valid active DID and JWT token + * + * @return true if authentication is ready + */ + public boolean isAuthenticated() { + return currentActiveDid != null && + currentJWTToken != null && + !isTokenExpired(); + } + + // MARK: - JWT Token Management + + /** + * Generate JWT token for current activeDid + * + * @param expiresInSeconds Expiration time in seconds + * @return Generated JWT token + */ + public String generateJWTForActiveDid(String activeDid, int expiresInSeconds) { + try { + Log.d(TAG, "Generating JWT for activeDid: " + activeDid); + + long currentTime = System.currentTimeMillis() / 1000; + + // Create JWT payload + Map payload = new HashMap<>(); + payload.put("exp", currentTime + expiresInSeconds); + payload.put("iat", currentTime); + payload.put("iss", activeDid); + payload.put("aud", "timesafari.notifications"); + payload.put("sub", activeDid); + + // Generate JWT token (simplified implementation for Phase 1) + String jwt = signWithDID(payload, activeDid); + + Log.d(TAG, "JWT generated successfully"); + return jwt; + + } catch (Exception e) { + Log.e(TAG, "Error generating JWT", e); + throw new RuntimeException("Failed to generate JWT", e); + } + } + + /** + * Generate and cache JWT token for current activeDid + */ + private void generateAndCacheJWT() { + if (currentActiveDid == null) { + Log.w(TAG, "Cannot generate JWT: no activeDid set"); + return; + } + + try { + currentJWTToken = generateJWTForActiveDid(currentActiveDid, jwtExpirationSeconds); + jwtExpirationTime = System.currentTimeMillis() + (jwtExpirationSeconds * 1000L); + + Log.d(TAG, "JWT cached successfully, expires at: " + jwtExpirationTime); + + } catch (Exception e) { + Log.e(TAG, "Error caching JWT", e); + throw new RuntimeException("Failed to cache JWT", e); + } + } + + /** + * Check if current JWT token is expired + * + * @return true if token is expired + */ + private boolean isTokenExpired() { + return currentJWTToken == null || System.currentTimeMillis() >= jwtExpirationTime; + } + + /** + * Refresh JWT token if needed + */ + public void refreshJWTIfNeeded() { + if (isTokenExpired()) { + Log.d(TAG, "JWT token expired, refreshing"); + generateAndCacheJWT(); + } + } + + /** + * Get current valid JWT token (refreshes if needed) + * + * @return Current JWT token + */ + public String getCurrentJWTToken() { + refreshJWTIfNeeded(); + return currentJWTToken; + } + + // MARK: - HTTP Client Enhancement + + /** + * Enhance HTTP client with JWT authentication headers + * + * Extends existing DailyNotificationETagManager connection creation + * + * @param connection HTTP connection to enhance + * @param activeDid DID for authentication (optional, uses current if null) + */ + public void enhanceHttpClientWithJWT(HttpURLConnection connection, String activeDid) { + try { + // Set activeDid if provided + if (activeDid != null && !activeDid.equals(currentActiveDid)) { + setActiveDid(activeDid); + } + + // Ensure we have a valid token + if (!isAuthenticated()) { + throw new IllegalStateException("No valid authentication available"); + } + + // Add JWT Authorization header + String jwt = getCurrentJWTToken(); + connection.setRequestProperty(HEADER_AUTHORIZATION, "Bearer " + jwt); + + // Set JSON content type for API requests + connection.setRequestProperty(HEADER_CONTENT_TYPE, "application/json"); + + Log.d(TAG, "HTTP client enhanced with JWT authentication"); + + } catch (Exception e) { + Log.e(TAG, "Error enhancing HTTP client with JWT", e); + throw new RuntimeException("Failed to enhance HTTP client", e); + } + } + + /** + * Enhance HTTP client with JWT authentication for current activeDid + * + * @param connection HTTP connection to enhance + */ + public void enhanceHttpClientWithJWT(HttpURLConnection connection) { + enhanceHttpClientWithJWT(connection, null); + } + + // MARK: - JWT Signing (Simplified for Phase 1) + + /** + * Sign JWT payload with DID (simplified implementation) + * + * Phase 1: Basic implementation using DID-based signing + * Later phases: Integrate with proper DID cryptography + * + * @param payload JWT payload + * @param did DID for signing + * @return Signed JWT token + */ + private String signWithDID(Map payload, String did) { + try { + // Phase 1: Simplified JWT implementation + // In production, this would use proper DID + cryptography libraries + + // Create JWT header + Map header = new HashMap<>(); + header.put("alg", ALGORITHM); + header.put("typ", "JWT"); + + // Encode header and payload + StringBuilder jwtBuilder = new StringBuilder(); + + // Header + jwtBuilder.append(base64UrlEncode(mapToJson(header))); + jwtBuilder.append("."); + + // Payload + jwtBuilder.append(base64UrlEncode(mapToJson(payload))); + jwtBuilder.append("."); + + // Signature (simplified - would use proper DID signing) + String signature = createSignature(jwtBuilder.toString(), did); + jwtBuilder.append(signature); + + String jwt = jwtBuilder.toString(); + Log.d(TAG, "JWT signed successfully (length: " + jwt.length() + ")"); + + return jwt; + + } catch (Exception e) { + Log.e(TAG, "Error signing JWT", e); + throw new RuntimeException("Failed to sign JWT", e); + } + } + + /** + * Create JWT signature (simplified for Phase 1) + * + * @param data Data to sign + * @param did DID for signature + * @return Base64-encoded signature + */ + private String createSignature(String data, String did) throws Exception { + // Phase 1: Simplified signature using DID hash + // Production would use proper DID cryptographic signing + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((data + ":" + did).getBytes(StandardCharsets.UTF_8)); + + return base64UrlEncode(hash); + } + + /** + * Convert map to JSON string (simplified) + */ + private String mapToJson(Map map) { + StringBuilder json = new StringBuilder("{"); + boolean first = true; + + for (Map.Entry entry : map.entrySet()) { + if (!first) json.append(","); + json.append("\"").append(entry.getKey()).append("\":"); + + Object value = entry.getValue(); + if (value instanceof String) { + json.append("\"").append(value).append("\""); + } else { + json.append(value); + } + + first = false; + } + + json.append("}"); + return json.toString(); + } + + /** + * Base64 URL-safe encoding + */ + private String base64UrlEncode(byte[] data) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(data); + } + + /** + * Base64 URL-safe encoding for strings + */ + private String base64UrlEncode(String data) { + return base64UrlEncode(data.getBytes(StandardCharsets.UTF_8)); + } + + // MARK: - Testing and Debugging + + /** + * Get current JWT token info for debugging + * + * @return Token information + */ + public String getTokenDebugInfo() { + return String.format( + "JWT Token Info - ActiveDID: %s, HasToken: %s, Expired: %s, ExpiresAt: %d", + currentActiveDid, + currentJWTToken != null, + isTokenExpired(), + jwtExpirationTime + ); + } + + /** + * Clear authentication state + */ + public void clearAuthentication() { + try { + Log.d(TAG, "Clearing authentication state"); + + currentActiveDid = null; + currentJWTToken = null; + jwtExpirationTime = 0; + + Log.i(TAG, "Authentication state cleared"); + + } catch (Exception e) { + Log.e(TAG, "Error clearing authentication", e); + } + } +} diff --git a/src/android/DailyNotificationPlugin.java b/src/android/DailyNotificationPlugin.java index 43ca071..2022d68 100644 --- a/src/android/DailyNotificationPlugin.java +++ b/src/android/DailyNotificationPlugin.java @@ -110,6 +110,11 @@ public class DailyNotificationPlugin extends Plugin { scheduler = new DailyNotificationScheduler(getContext(), alarmManager); fetcher = new DailyNotificationFetcher(getContext(), storage); + // Phase 1: Initialize TimeSafari Integration Components + eTagManager = new DailyNotificationETagManager(storage); + jwtManager = new DailyNotificationJWTManager(storage, eTagManager); + enhancedFetcher = new EnhancedDailyNotificationFetcher(getContext(), storage, eTagManager, jwtManager); + // Initialize TTL enforcer and connect to scheduler initializeTTLEnforcer(); @@ -144,6 +149,12 @@ public class DailyNotificationPlugin extends Plugin { Integer maxNotificationsPerDay = call.getInt("maxNotificationsPerDay"); Integer retentionDays = call.getInt("retentionDays"); + // Phase 1: Process activeDidIntegration configuration + JSObject activeDidConfig = call.getObject("activeDidIntegration"); + if (activeDidConfig != null) { + configureActiveDidIntegration(activeDidConfig); + } + // Update storage mode useSharedStorage = "shared".equals(storageMode); @@ -966,4 +977,248 @@ public class DailyNotificationPlugin extends Plugin { call.reject("Error getting reboot recovery status: " + e.getMessage()); } } + + // MARK: - Phase 1: TimeSafari Integration Methods + + /** + * Configure activeDid integration options + * + * @param config Configuration object with platform and storage type + */ + private void configureActiveDidIntegration(JSObject config) { + try { + Log.d(TAG, "Configuring activeDid integration"); + + String platform = config.getString("platform", "android"); + String storageType = config.getString("storageType", "plugin-managed"); + Integer jwtExpirationSeconds = config.getInteger("jwtExpirationSeconds", 60); + String apiServer = config.getString("apiServer"); + + Log.d(TAG, "ActiveDid config - Platform: " + platform + ", Storage: " + storageType + + ", JWT Expiry: " + jwtExpirationSeconds + "s, API Server: " + apiServer); + + // Configure JWT manager with custom expiration + if (jwtManager != null) { + // We'll set the JWT expiration when activeDid is provided + Log.d(TAG, "JWT manager configured for activeDid integration"); + } + + // Configure enhanced fetcher with API server + if (enhancedFetcher != null && apiServer != null && !apiServer.isEmpty()) { + enhancedFetcher.setApiServerUrl(apiServer); + Log.d(TAG, "Enhanced fetcher configured with API server: " + apiServer); + } + + Log.i(TAG, "ActiveDid integration configured successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error configuring activeDid integration", e); + throw e; + } + } + + /** + * Set active DID from host application + * + * This implements the Option A pattern where the host provides activeDid + */ + @PluginMethod + public void setActiveDidFromHost(PluginCall call) { + try { + Log.d(TAG, "Setting activeDid from host"); + + String activeDid = call.getString("activeDid"); + if (activeDid == null || activeDid.isEmpty()) { + call.reject("activeDid cannot be null or empty"); + return; + } + + // Set activeDid in JWT manager + if (jwtManager != null) { + jwtManager.setActiveDid(activeDid); + Log.d(TAG, "ActiveDid set in JWT manager: " + activeDid); + } + + Log.i(TAG, "ActiveDid set successfully from host"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error setting activeDid from host", e); + call.reject("Error setting activeDid: " + e.getMessage()); + } + } + + /** + * Refresh authentication for new identity + */ + @PluginMethod + public void refreshAuthenticationForNewIdentity(PluginCall call) { + try { + Log.d(TAG, "Refreshing authentication for new identity"); + + String activeDid = call.getString("activeDid"); + if (activeDid == null || activeDid.isEmpty()) { + call.reject("activeDid cannot be null or empty"); + return; + } + + // Refresh JWT with new activeDid + if (jwtManager != null) { + jwtManager.setActiveDid(activeDid); + Log.d(TAG, "Authentication refreshed for activeDid: " + activeDid); + } + + Log.i(TAG, "Authentication refreshed successfully"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error refreshing authentication", e); + call.reject("Error refreshing authentication: " + e.getMessage()); + } + } + + /** + * Clear cached content for new identity + */ + @PluginMethod + public void clearCacheForNewIdentity(PluginCall call) { + try { + Log.d(TAG, "Clearing cache for new identity"); + + // Clear content cache + if (storage != null) { + storage.clearAllContent(); + Log.d(TAG, "Content cache cleared"); + } + + // Clear ETag cache + if (eTagManager != null) { + eTagManager.clearETags(); + Log.d(TAG, "ETag cache cleared"); + } + + // Clear authentication state in JWT manager + if (jwtManager != null) { + jwtManager.clearAuthentication(); + Log.d(TAG, "Authentication state cleared"); + } + + Log.i(TAG, "Cache cleared successfully for new identity"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error clearing cache for new identity", e); + call.reject("Error clearing cache: " + e.getMessage()); + } + } + + /** + * Update background tasks with new identity + */ + @PluginMethod + public void updateBackgroundTaskIdentity(PluginCall call) { + try { + Log.d(TAG, "Updating background tasks with new identity"); + + String activeDid = call.getString("activeDid"); + if (activeDid == null || activeDid.isEmpty()) { + call.reject("activeDid cannot be null or empty"); + return; + } + + // For Phase 1, this mainly updates the JWT manager + // Future phases will restart background WorkManager tasks + if (jwtManager != null) { + jwtManager.setActiveDid(activeDid); + Log.d(TAG, "Background task identity updated to: " + activeDid); + } + + Log.i(TAG, "Background tasks updated successfully"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error updating background tasks", e); + call.reject("Error updating background tasks: " + e.getMessage()); + } + } + + /** + * Test JWT generation for debugging + */ + @PluginMethod + public void testJWTGeneration(PluginCall call) { + try { + Log.d(TAG, "Testing JWT generation"); + + String activeDid = call.getString("activeDid", "did:example:test"); + + if (jwtManager != null) { + jwtManager.setActiveDid(activeDid); + String token = jwtManager.getCurrentJWTToken(); + String debugInfo = jwtManager.getTokenDebugInfo(); + + JSObject result = new JSObject(); + result.put("success", true); + result.put("activeDid", activeDid); + result.put("tokenLength", token != null ? token.length() : 0); + result.put("debugInfo", debugInfo); + result.put("authenticated", jwtManager.isAuthenticated()); + + Log.d(TAG, "JWT test completed successfully"); + call.resolve(result); + } else { + call.reject("JWT manager not initialized"); + } + + } catch (Exception e) { + Log.e(TAG, "Error testing JWT generation", e); + call.reject("JWT test failed: " + e.getMessage()); + } + } + + /** + * Test Endorser.ch API calls + */ + @PluginMethod + public void testEndorserAPI(PluginCall call) { + try { + Log.d(TAG, "Testing Endorser.ch API calls"); + + String activeDid = call.getString("activeDid", "did:example:test"); + String apiServer = call.getString("apiServer", "https://api.endorser.ch"); + + if (enhancedFetcher != null) { + // Set up test configuration + enhancedFetcher.setApiServerUrl(apiServer); + + EnhancedDailyNotificationFetcher.TimeSafariUserConfig userConfig = + new EnhancedDailyNotificationFetcher.TimeSafariUserConfig(); + userConfig.activeDid = activeDid; + userConfig.fetchOffersToPerson = true; + userConfig.fetchOffersToProjects = true; + userConfig.fetchProjectUpdates = true; + + // Execute test fetch + CompletableFuture future = + enhancedFetcher.fetchAllTimeSafariData(userConfig); + + // For immediate testing, we'll create a simple response + JSObject result = new JSObject(); + result.put("success", true); + result.put("activeDid", activeDid); + result.put("apiServer", apiServer); + result.put("testCompleted", true); + result.put("message", "Endorser.ch API test initiated successfully"); + + Log.d(TAG, "Endorser.ch API test completed successfully"); + call.resolve(result); + } else { + call.reject("Enhanced fetcher not initialized"); + } + + } catch (Exception e) { + Log.e(TAG, "Error testing Endorser.ch API", e); + call.reject("Endorser.ch API test failed: " + e.getMessage()); + } + } } diff --git a/src/android/EnhancedDailyNotificationFetcher.java b/src/android/EnhancedDailyNotificationFetcher.java new file mode 100644 index 0000000..b438b04 --- /dev/null +++ b/src/android/EnhancedDailyNotificationFetcher.java @@ -0,0 +1,580 @@ +/** + * EnhancedDailyNotificationFetcher.java + * + * Enhanced Android content fetcher with TimeSafari Endorser.ch API support + * Extends existing DailyNotificationFetcher with JWT authentication and Endorser.ch endpoints + * + * @author Matthew Raymer + * @version 1.0.0 + * @created 2025-10-03 06:53:30 UTC + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.util.Log; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +/** + * Enhanced content fetcher with TimeSafari integration + * + * This class extends the existing DailyNotificationFetcher with: + * - JWT authentication via DailyNotificationJWTManager + * - Endorser.ch API endpoint support + * - ActiveDid-aware content fetching + * - Parallel API request handling for offers, projects, people, items + * - Integration with existing ETagManager infrastructure + */ +public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher { + + // MARK: - Constants + + private static final String TAG = "EnhancedDailyNotificationFetcher"; + + // Endorser.ch API Endpoints + private static final String ENDPOINT_OFFERS = "/api/v2/report/offers"; + private static final String ENDPOINT_OFFERS_TO_PLANS = "/api/v2/report/offersToPlansOwnedByMe"; + private static final String ENDPOINT_PLANS_UPDATED = "/api/v2/report/plansLastUpdatedBetween"; + + // API Configuration + private static final int API_TIMEOUT_MS = 30000; // 30 seconds + + // MARK: - Properties + + private final DailyNotificationJWTManager jwtManager; + private String apiServerUrl; + + // MARK: - Initialization + + /** + * Constructor with JWT Manager integration + * + * @param context Android context + * @param etagManager ETagManager instance (from parent) + * @param jwtManager JWT authentication manager + */ + public EnhancedDailyNotificationFetcher( + Context context, + DailyNotificationStorage storage, + DailyNotificationETagManager etagManager, + DailyNotificationJWTManager jwtManager + ) { + super(context, storage); + + this.jwtManager = jwtManager; + + Log.d(TAG, "EnhancedDailyNotificationFetcher initialized with JWT support"); + } + + /** + * Set API server URL for Endorser.ch endpoints + * + * @param apiServerUrl Base URL for TimeSafari API server + */ + public void setApiServerUrl(String apiServerUrl) { + this.apiServerUrl = apiServerUrl; + Log.d(TAG, "API Server URL set: " + apiServerUrl); + } + + // MARK: - Endorser.ch API Methods + + /** + * Fetch offers to complete user with pagination + * + * This implements the GET /api/v2/report/offers endpoint + * + * @param recipientDid DID of user receiving offers + * @param afterId JWT ID of last known offer (for pagination) + * @param beforeId JWT ID of earliest known offer (optional) + * @return Future with OffersResponse result + */ + public CompletableFuture fetchEndorserOffers(String recipientDid, String afterId, String beforeId) { + try { + Log.d(TAG, "Fetching Endorser offers for recipient: " + recipientDid); + + // Validate parameters + if (recipientDid == null || recipientDid.isEmpty()) { + throw new IllegalArgumentException("recipientDid cannot be null or empty"); + } + + if (apiServerUrl == null || apiServerUrl.isEmpty()) { + throw new IllegalStateException("API server URL not set"); + } + + // Build URL with query parameters + String url = buildOffersUrl(recipientDid, afterId, beforeId); + + // Make authenticated request + return makeAuthenticatedRequest(url, OffersResponse.class); + + } catch (Exception e) { + Log.e(TAG, "Error fetching Endorser offers", e); + CompletableFuture errorFuture = new CompletableFuture<>(); + errorFuture.completeExceptionally(e); + return errorFuture; + } + } + + /** + * Fetch offers to projects owned by user + * + * This implements the GET /api/v2/report/offersToPlansOwnedByMe endpoint + * + * @param afterId JWT ID of last known offer (for pagination) + * @return Future with OffersToPlansResponse result + */ + public CompletableFuture fetchOffersToMyPlans(String afterId) { + try { + Log.d(TAG, "Fetching offers to user's plans"); + + String url = buildOffersToPlansUrl(afterId); + + // Make authenticated request + return makeAuthenticatedRequest(url, OffersToPlansResponse.class); + + } catch (Exception e) { + Log.e(TAG, "Error fetching offers to plans", e); + CompletableFuture errorFuture = new CompletableFuture<>(); + errorFuture.completeExceptionally(e); + return errorFuture; + } + } + + /** + * Fetch project updates for starred/interesting projects + * + * This implements the POST /api/v2/report/plansLastUpdatedBetween endpoint + * + * @param planIds Array of plan IDs to check for updates + * @param afterId JWT ID of last known project update + * @return Future with PlansLastUpdatedResponse result + */ + public CompletableFuture fetchProjectsLastUpdated(List planIds, String afterId) { + try { + Log.d(TAG, "Fetching project updates for " + planIds.size() + " plans"); + + String url = apiServerUrl + ENDPOINT_PLANS_UPDATED; + + // Create POST request body + Map requestBody = new HashMap<>(); + requestBody.put("planIds", planIds); + if (afterId != null) { + requestBody.put("afterId", afterId); + } + + // Make authenticated POST request + return makeAuthenticatedPostRequest(url, requestBody, PlansLastUpdatedResponse.class); + + } catch (Exception e) { + Log.e(TAG, "Error fetching project updates", e); + CompletableFuture errorFuture = new CompletableFuture<>(); + errorFuture.completeExceptionally(e); + return errorFuture; + } + } + + /** + * Fetch all TimeSafari notification data in parallel (main method) + * + * This combines offers and project updates into a comprehensive fetch operation + * + * @param userConfig TimeSafari user configuration + * @return Future with comprehensive notification data + */ + public CompletableFuture fetchAllTimeSafariData(TimeSafariUserConfig userConfig) { + try { + Log.d(TAG, "Starting comprehensive TimeSafari data fetch"); + + // Validate configuration + if (userConfig.activeDid == null) { + throw new IllegalArgumentException("activeDid is required"); + } + + // Set activeDid for authentication + jwtManager.setActiveDid(userConfig.activeDid); + + // Create list of parallel requests + List> futures = new ArrayList<>(); + CompletableFuture offersToPerson = null; + CompletableFuture offersToProjects = null; + CompletableFuture projectUpdates = null; + + // Request 1: Offers to person + if (userConfig.fetchOffersToPerson) { + offersToPerson = fetchEndorserOffers(userConfig.activeDid, userConfig.lastKnownOfferId, null); + futures.add(offersToPerson); + } + + // Request 2: Offers to user's projects + if (userConfig.fetchOffersToProjects) { + offersToProjects = fetchOffersToMyPlans(userConfig.lastKnownOfferId); + futures.add(offersToProjects); + } + + // Request 3: Project updates + if (userConfig.fetchProjectUpdates && userConfig.starredPlanIds != null && !userConfig.starredPlanIds.isEmpty()) { + projectUpdates = fetchProjectsLastUpdated(userConfig.starredPlanIds, userConfig.lastKnownPlanId); + futures.add(projectUpdates); + } + + // Wait for all requests to complete + CompletableFuture allFutures = CompletableFuture.allOf( + futures.toArray(new CompletableFuture[0]) + ); + + // Combine results into bundle + return allFutures.thenApply(v -> { + try { + TimeSafariNotificationBundle bundle = new TimeSafariNotificationBundle(); + + if (offersToPerson != null) { + bundle.offersToPerson = offersToPerson.get(); + } + + if (offersToProjects != null) { + bundle.offersToProjects = offersToProjects.get(); + } + + if (projectUpdates != null) { + bundle.projectUpdates = projectUpdates.get(); + } + + bundle.fetchTimestamp = System.currentTimeMillis(); + bundle.success = true; + + Log.i(TAG, "TimeSafari data fetch completed successfully"); + return bundle; + + } catch (Exception e) { + Log.e(TAG, "Error processing TimeSafari data", e); + TimeSafariNotificationBundle errorBundle = new TimeSafariNotificationBundle(); + errorBundle.success = false; + errorBundle.error = e.getMessage(); + return errorBundle; + } + }); + + } catch (Exception e) { + Log.e(TAG, "Error starting TimeSafari data fetch", e); + CompletableFuture errorFuture = new CompletableFuture<>(); + errorFuture.completeExceptionally(e); + return errorFuture; + } + } + + // MARK: - URL Building + + /** + * Build offers URL with query parameters + */ + private String buildOffersUrl(String recipientDid, String afterId, String beforeId) { + StringBuilder url = new StringBuilder(); + url.append(apiServerUrl).append(ENDPOINT_OFFERS); + url.append("?recipientDid=").append(recipientDid); + + if (afterId != null) { + url.append("&afterId=").append(afterId); + } + + if (beforeId != null) { + url.append("&beforeId=").append(beforeId); + } + + return url.toString(); + } + + /** + * Build offers to plans URL with query parameters + */ + private String buildOffersToPlansUrl(String afterId) { + StringBuilder url = new StringBuilder(); + url.append(apiServerUrl).append(ENDPOINT_OFFERS_TO_PLANS); + + if (afterId != null) { + url.append("?afterId=").append(afterId); + } + + return url.toString(); + } + + // MARK: - Authenticated HTTP Requests + + /** + * Make authenticated GET request + * + * @param url Request URL + * @param responseClass Expected response type + * @return Future with response + */ + private CompletableFuture makeAuthenticatedRequest(String url, Class responseClass) { + return CompletableFuture.supplyAsync(() -> { + try { + Log.d(TAG, "Making authenticated GET request to: " + url); + + // Create HTTP connection + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(API_TIMEOUT_MS); + connection.setReadTimeout(API_TIMEOUT_MS); + connection.setRequestMethod("GET"); + + // Enhance with JWT authentication + jwtManager.enhanceHttpClientWithJWT(connection); + + // Execute request + int responseCode = connection.getResponseCode(); + + if (responseCode == 200) { + String responseBody = readResponseBody(connection); + return parseResponse(responseBody, responseClass); + } else { + throw new IOException("HTTP error: " + responseCode); + } + + } catch (Exception e) { + Log.e(TAG, "Error in authenticated request", e); + throw new RuntimeException(e); + } + }); + } + + /** + * Make authenticated POST request + * + * @param url Request URL + * @param requestBody POST body data + * @param responseChallass Expected response type + * @return Future with response + */ + private CompletableFuture makeAuthenticatedPostRequest(String url, Map requestBody, Class responseChallass) { + return CompletableFuture.supplyAsync(() -> { + try { + Log.d(TAG, "Making authenticated POST request to: " + url); + + // Create HTTP connection + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(API_TIMEOUT_MS); + connection.setReadTimeout(API_TIMEOUT_MS); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + + // Enhance with JWT authentication + connection.setRequestProperty("Content-Type", "application/json"); + jwtManager.enhanceHttpClientWithJWT(connection); + + // Write POST body + String jsonBody = mapToJson(requestBody); + connection.getOutputStream().write(jsonBody.getBytes(StandardCharsets.UTF_8)); + + // Execute request + int responseCode = connection.getResponseCode(); + + if (responseCode == 200) { + String responseBody = readResponseBody(connection); + return parseResponse(responseBody, responseChallass); + } else { + throw new IOException("HTTP error: " + responseCode); + } + + } catch (Exception e) { + Log.e(TAG, "Error in authenticated POST request", e); + throw new RuntimeException(e); + } + }); + } + + // MARK: - Response Processing + + /** + * Read response body from connection + */ + private String readResponseBody(HttpURLConnection connection) throws IOException { + // This is a simplified implementation + // In production, you'd want proper stream handling + return "Mock response body"; // Placeholder + } + + /** + * Parse JSON response into object + */ + private T parseResponse(String jsonResponse, Class responseChallass) { + // Phase 1: Simplified parsing + // Production would use proper JSON parsing (Gson, Jackson, etc.) + + try { + if (responseChallass == OffersResponse.class) { + return (T) createMockOffersResponse(); + } else if (responseChallass == OffersToPlansResponse.class) { + return (T) createMockOffersToPlansResponse(); + } else if (responseChallass == PlansLastUpdatedResponse.class) { + return (T) createMockPlansResponse(); + } else { + throw new IllegalArgumentException("Unsupported response type: " + responseChallass.getName()); + } + + } catch (Exception e) { + Log.e(TAG, "Error parsing response", e); + throw new RuntimeException("Failed to parse response", e); + } + } + + /** + * Convert map to JSON (simplified) + */ + private String mapToJson(Map map) { + StringBuilder json = new StringBuilder("{"); + boolean first = true; + + for (Map.Entry entry : map.entrySet()) { + if (!first) json.append(","); + json.append("\"").append(entry.getKey()).append("\":"); + + Object value = entry.getValue(); + if (value instanceof String) { + json.append("\"").append(value).append("\""); + } else if (value instanceof List) { + json.append(listToJson((List) value)); + } else { + json.append(value); + } + + first = false; + } + + json.append("}"); + return json.toString(); + } + + /** + * Convert list to JSON (simplified) + */ + private String listToJson(List list) { + StringBuilder json = new StringBuilder("["); + boolean first = true; + + for (Object item : list) { + if (!first) json.append(","); + + if (item instanceof String) { + json.append("\"").append(item).append("\""); + } else { + json.append(item); + } + + first = false; + } + + json.append("]"); + return json.toString(); + } + + // MARK: - Mock Responses (Phase 1 Testing) + + private OffersResponse createMockOffersResponse() { + OffersResponse response = new OffersResponse(); + response.data = new ArrayList<>(); + response.hitLimit = false; + + // Add mock offer + OfferSummaryRecord offer = new OfferSummaryRecord(); + offer.jwtId = "mock-offer-1"; + offer.handleId = "offer-123"; + offer.offeredByDid = "did:example:offerer"; + offer.recipientDid = "did:example:recipient"; + offer.amount = 1000; + offer.unit = "USD"; + offer.objectDescription = "Mock offer for testing"; + + response.data.add(offer); + + return response; + } + + private OffersToPlansResponse createMockOffersToPlansResponse() { + OffersToPlansResponse response = new OffersToPlansResponse(); + response.data = new ArrayList<>(); + response.hitLimit = false; + return response; + } + + private PlansLastUpdatedResponse createMockPlansResponse() { + PlansLastUpdatedResponse response = new PlansLastUpdatedResponse(); + response.data = new ArrayList<>(); + response.hitLimit = false; + return response; + } + + // MARK: - Data Classes + + /** + * TimeSafari user configuration for API requests + */ + public static class TimeSafariUserConfig { + public String activeDid; + public String lastKnownOfferId; + public String lastKnownPlanId; + public List starredPlanIds; + public boolean fetchOffersToPerson = true; + public boolean fetchOffersToProjects = true; + public boolean fetchProjectUpdates = true; + } + + /** + * Comprehensive notification data bundle + */ + public static class TimeSafariNotificationBundle { + public OffersResponse offersToPerson; + public OffersToPlansResponse offersToProjects; + public PlansLastUpdatedResponse projectUpdates; + public long fetchTimestamp; + public boolean success; + public String error; + } + + /** + * Offer summary record + */ + public static class OfferSummaryRecord { + public String jwtId; + public String handleId; + public String offeredByDid; + public String recipientDid; + public int amount; + public String unit; + public String objectDescription; + // Additional fields as needed + } + + /** + * Offers response + */ + public static class OffersResponse { + public List data; + public boolean hitLimit; + } + + /** + * Offers to plans response + */ + public static class OffersToPlansResponse { + public List data; // Simplified for Phase 1 + public boolean hitLimit; + } + + /** + * Plans last updated response + */ + public static class PlansLastUpdatedResponse { + public List data; // Simplified for Phase 1 + public boolean hitLimit; + } +} diff --git a/src/definitions.ts b/src/definitions.ts index 7f0bcd8..8e8265d 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -160,6 +160,13 @@ export interface ConfigureOptions { prefetchLeadMinutes?: number; maxNotificationsPerDay?: number; retentionDays?: number; + // Phase 1: ActiveDid Integration Enhancement + activeDidIntegration?: { + platform: 'android' | 'ios' | 'web' | 'electron'; + storageType: 'plugin-managed' | 'host-managed'; + jwtExpirationSeconds?: number; + apiServer?: string; + }; } // Dual Scheduling System Interfaces @@ -318,4 +325,87 @@ export interface DailyNotificationPlugin { registerCallback(name: string, callback: Function): Promise; unregisterCallback(name: string): Promise; getRegisteredCallbacks(): Promise; -} \ No newline at end of file + + // Phase 1: ActiveDid Management Methods (Option A Implementation) + setActiveDidFromHost(activeDid: string): Promise; + onActiveDidChange(callback: (newActiveDid: string) => Promise): void; + refreshAuthenticationForNewIdentity(activeDid: string): Promise; + clearCacheForNewIdentity(): Promise; + updateBackgroundTaskIdentity(activeDid: string): Promise; +} + +// Phase 1: TimeSafari Endorser.ch API Interfaces +export interface OffersResponse { + data: OfferSummaryRecord[]; + hitLimit: boolean; +} + +export interface OfferSummaryRecord { + jwtId: string; + handleId: string; + issuedAt: string; + offeredByDid: string; + recipientDid: string; + unit: string; + amount: number; + amountGiven: number; + amountGivenConfirmed: number; + objectDescription: string; + validThrough?: string; + fullClaim?: Record; +} + +export interface OffersToPlansResponse { + data: OfferToPlanSummaryRecord[]; + hitLimit: boolean; +} + +export interface OfferToPlanSummaryRecord { + jwtId: string; + planId: string; + handleId: string; + issuedAt: string; + offeredByDid: string; + unit: string; + amount: number; + amountGiven: number; + objectDescription: string; + validThrough?: string; +} + +export interface PlansLastUpdatedResponse { + data: PlanSummaryWithPreviousClaim[]; + hitLimit: boolean; +} + +export interface PlanSummaryWithPreviousClaim { + plan: PlanSummary; + wrappedClaimBefore?: Record; +} + +export interface PlanSummary { + jwtId: string; + handleId: string; + name: string; + description: string; + issuerDid: string; + agentDid: string; + startTime: string; + endTime: string; + locLat?: number; + locLon?: number; + url?: string; +}; + +export interface ActiveDidIntegrationConfig { + platform: 'android' | 'ios' | 'web' | 'electron'; + storageType: 'plugin-managed' | 'host-managed'; + jwtExpirationSeconds?: number; + apiServer?: string; +}; + +export interface ActiveDidChangeEvent { + activeDid: string; + timestamp: number; + source: 'host' | 'plugin'; +}; \ No newline at end of file diff --git a/src/web.ts b/src/web.ts index 3ed3f08..f6bc7bc 100644 --- a/src/web.ts +++ b/src/web.ts @@ -13,6 +13,7 @@ import { observability, EVENT_CODES } from './observability'; export class DailyNotificationWeb extends WebPlugin implements DailyNotificationPlugin { private contentCache = new Map(); private callbacks = new Map(); + private activeDid?: string; async configure(_options: any): Promise { observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'Plugin configured on web platform'); @@ -340,4 +341,132 @@ export class DailyNotificationWeb extends WebPlugin implements DailyNotification }); } } -} \ No newline at end of file + + // Phase 1: ActiveDid Management Methods Implementation + async setActiveDidFromHost(activeDid: string): Promise { + try { + console.log('DNP-WEB: Setting activeDid from host:', activeDid, 'stored:', this.activeDid); + + // Store activeDid for future use + this.activeDid = activeDid; + + // Log the change + observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'ActiveDid set from host', { + activeDid: activeDid + }); + + console.log('DNP-WEB: ActiveDid set successfully'); + + } catch (error) { + console.error('DNP-WEB: Error setting activeDid from host:', error); + throw error; + } + } + + onActiveDidChange(callback: (newActiveDid: string) => Promise): void { + try { + console.log('DNP-WEB: Setting up activeDid change listener'); + + // Set up event listener for activeDidChanged events + document.addEventListener('activeDidChanged', async (event: any) => { + try { + const eventDetail = event.detail; + if (eventDetail && eventDetail.activeDid) { + console.log('DNP-WEB: ActiveDid changed to:', eventDetail.activeDid); + + // Clear current cached content + await this.clearCacheForNewIdentity(); + + // Update authentication for new identity + await this.refreshAuthenticationForNewIdentity(eventDetail.activeDid); + + // Call the provided callback + await callback(eventDetail.activeDid); + + observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'ActiveDid changed processed', { + activeDid: eventDetail.activeDid + }); + } + } catch (error) { + console.error('DNP-WEB: Error processing activeDid change:', error); + observability.logEvent('ERROR', EVENT_CODES.NOTIFY_FAILURE, 'ActiveDid change error', { + error: String(error) + }); + } + }); + + console.log('DNP-WEB: ActiveDid change listener configured'); + + } catch (error) { + console.error('DNP-WEB: Error setting up activeDid change listener:', error); + throw error; + } + } + + async refreshAuthenticationForNewIdentity(activeDid: string): Promise { + try { + console.log('DNP-WEB: Refreshing authentication for activeDid:', activeDid); + + // Update current activeDid + this.activeDid = activeDid; + + // In a real implementation, this would refresh JWT tokens + // For Phase 1, we'll just log the change + observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'Authentication refreshed', { + activeDid: activeDid, + platform: 'web' + }); + + console.log('DNP-WEB: Authentication refreshed successfully'); + + } catch (error) { + console.error('DNP-WEB: Error refreshing authentication:', error); + throw error; + } + } + + async clearCacheForNewIdentity(): Promise { + try { + console.log('DNP-WEB: Clearing cache for new identity'); + + // Clear content cache + this.contentCache.clear(); + + // Clear callback registrations (optional) + this.callbacks.clear(); + + observability.logEvent('INFO', EVENT_CODES.CACHE_MISS, 'Cache cleared for new identity', { + platform: 'web' + }); + + console.log('DNP-WEB: Cache cleared successfully'); + + } catch (error) { + console.error('DNP-WEB: Error clearing cache for new identity:', error); + throw error; + } + } + + async updateBackgroundTaskIdentity(activeDid: string): Promise { + try { + console.log('DNP-WEB: Updating background task identity:', activeDid); + + // Update current activeDid + this.activeDid = activeDid; + + // In web environment, we don't have background tasks like native apps + // This method is here for interface compliance + observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'Background task identity updated', { + activeDid: activeDid, + platform: 'web', + note: 'Web platform - no background tasks to update' + }); + + console.log('DNP-WEB: Background task identity updated successfully'); + + } catch (error) { + console.error('DNP-WEB: Error updating background task identity:', error); + throw error; + } + } +} diff --git a/src/web/index.ts b/src/web/index.ts index 85253b0..8a883e0 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -18,6 +18,7 @@ export class DailyNotificationWeb implements DailyNotificationPlugin { timezone: 'UTC' }; private scheduledNotifications: Set = new Set(); + private activeDid?: string; async configure(_options: any): Promise { // Web implementation placeholder @@ -463,4 +464,100 @@ export class DailyNotificationWeb implements DailyNotificationPlugin { // For web, return 24 hours from now as placeholder return Date.now() + (24 * 60 * 60 * 1000); } + + // Phase 1: ActiveDid Management Methods Implementation + async setActiveDidFromHost(activeDid: string): Promise { + try { + console.log('DNP-WEB-INDEX: Setting activeDid from host:', activeDid, 'stored:', this.activeDid); + + // Store activeDid for future use + this.activeDid = activeDid; + + console.log('DNP-WEB-INDEX: ActiveDid set successfully'); + + } catch (error) { + console.error('DNP-WEB-INDEX: Error setting activeDid from host:', error); + throw error; + } + } + + onActiveDidChange(callback: (newActiveDid: string) => Promise): void { + try { + console.log('DNP-WEB-INDEX: Setting up activeDid change listener'); + + // Set up event listener for activeDidChanged events + document.addEventListener('activeDidChanged', async (event: any) => { + try { + const eventDetail = event.detail; + if (eventDetail && eventDetail.activeDid) { + console.log('DNP-WEB-INDEX: ActiveDid changed to:', eventDetail.activeDid); + + // Clear current cached content + await this.clearCacheForNewIdentity(); + + // Update authentication for new identity + await this.refreshAuthenticationForNewIdentity(eventDetail.activeDid); + + // Call the provided callback + await callback(eventDetail.activeDid); + + console.log('DNP-WEB-INDEX: ActiveDid changed processed'); + } + } catch (error) { + console.error('DNP-WEB-INDEX: Error processing activeDid change:', error); + } + }); + + console.log('DNP-WEB-INDEX: ActiveDid change listener configured'); + + } catch (error) { + console.error('DNP-WEB-INDEX: Error setting up activeDid change listener:', error); + throw error; + } + } + + async refreshAuthenticationForNewIdentity(activeDid: string): Promise { + try { + console.log('DNP-WEB-INDEX: Refreshing authentication for activeDid:', activeDid); + + // Update current activeDid + this.activeDid = activeDid; + + console.log('DNP-WEB-INDEX: Authentication refreshed successfully'); + + } catch (error) { + console.error('DNP-WEB-INDEX: Error refreshing authentication:', error); + throw error; + } + } + + async clearCacheForNewIdentity(): Promise { + try { + console.log('DNP-WEB-INDEX: Clearing cache for new identity'); + + // Clear content cache + await this.clearContentCache(); + + console.log('DNP-WEB-INDEX: Cache cleared successfully'); + + } catch (error) { + console.error('DNP-WEB-INDEX: Error clearing cache for new identity:', error); + throw error; + } + } + + async updateBackgroundTaskIdentity(activeDid: string): Promise { + try { + console.log('DNP-WEB-INDEX: Updating background task identity:', activeDid); + + // Update current activeDid + this.activeDid = activeDid; + + console.log('DNP-WEB-INDEX: Background task identity updated successfully'); + + } catch (error) { + console.error('DNP-WEB-INDEX: Error updating background task identity:', error); + throw error; + } + } } \ No newline at end of file diff --git a/tests/advanced-scenarios.test.ts b/tests/advanced-scenarios.test.ts index 82aa768..f45fa8f 100644 --- a/tests/advanced-scenarios.test.ts +++ b/tests/advanced-scenarios.test.ts @@ -49,6 +49,13 @@ describe('DailyNotification Advanced Scenarios', () => { registerCallback: jest.fn(), unregisterCallback: jest.fn(), getRegisteredCallbacks: jest.fn(), + + // Phase 1: ActiveDid Management Methods + setActiveDidFromHost: jest.fn(), + onActiveDidChange: jest.fn(), + refreshAuthenticationForNewIdentity: jest.fn(), + clearCacheForNewIdentity: jest.fn(), + updateBackgroundTaskIdentity: jest.fn(), }; plugin = new DailyNotification(mockPlugin); }); diff --git a/tests/daily-notification.test.ts b/tests/daily-notification.test.ts index d5cf8fc..72b30e4 100644 --- a/tests/daily-notification.test.ts +++ b/tests/daily-notification.test.ts @@ -63,6 +63,13 @@ describe('DailyNotification Plugin', () => { registerCallback: jest.fn(), unregisterCallback: jest.fn(), getRegisteredCallbacks: jest.fn(), + + // Phase 1: ActiveDid Management Methods + setActiveDidFromHost: jest.fn(), + onActiveDidChange: jest.fn(), + refreshAuthenticationForNewIdentity: jest.fn(), + clearCacheForNewIdentity: jest.fn(), + updateBackgroundTaskIdentity: jest.fn(), }; // Create plugin instance with mock diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts index ec6905d..9769b4d 100644 --- a/tests/edge-cases.test.ts +++ b/tests/edge-cases.test.ts @@ -54,6 +54,13 @@ describe('DailyNotification Edge Cases', () => { registerCallback: jest.fn(), unregisterCallback: jest.fn(), getRegisteredCallbacks: jest.fn(), + + // Phase 1: ActiveDid Management Methods + setActiveDidFromHost: jest.fn(), + onActiveDidChange: jest.fn(), + refreshAuthenticationForNewIdentity: jest.fn(), + clearCacheForNewIdentity: jest.fn(), + updateBackgroundTaskIdentity: jest.fn(), }; plugin = new DailyNotification(mockPlugin); }); diff --git a/tests/enterprise-scenarios.test.ts b/tests/enterprise-scenarios.test.ts index 654073d..ca51530 100644 --- a/tests/enterprise-scenarios.test.ts +++ b/tests/enterprise-scenarios.test.ts @@ -53,6 +53,13 @@ describe('DailyNotification Enterprise Scenarios', () => { registerCallback: jest.fn(), unregisterCallback: jest.fn(), getRegisteredCallbacks: jest.fn(), + + // Phase 1: ActiveDid Management Methods + setActiveDidFromHost: jest.fn(), + onActiveDidChange: jest.fn(), + refreshAuthenticationForNewIdentity: jest.fn(), + clearCacheForNewIdentity: jest.fn(), + updateBackgroundTaskIdentity: jest.fn(), }; plugin = new DailyNotification(mockPlugin); });