From b585c4d18374bbebc6ba507a689a8e7774fe46bc Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Tue, 11 Nov 2025 05:03:25 +0000 Subject: [PATCH] feat(android): Add host-side setup for daily notification plugin Implement Android host-side integration for daily notification plugin by creating custom Application class and native content fetcher. Changes: - Add TimeSafariApplication.java: Custom Application class that registers NativeNotificationContentFetcher with the plugin on app startup - Add TimeSafariNativeFetcher.java: Implementation of NativeNotificationContentFetcher interface that fetches notification content from endorser API endpoint (/api/v2/report/plansLastUpdatedBetween) using JWT authentication - Update AndroidManifest.xml: Declare TimeSafariApplication as the custom Application class using android:name attribute - Add Gson dependency: Include com.google.code.gson:gson:2.10.1 in build.gradle for JSON parsing in the native fetcher This setup mirrors the test app configuration and enables the plugin's background content prefetching feature. The native fetcher will be called by the plugin 5 minutes before scheduled notification times to prefetch content for display. Author: Matthew Raymer --- android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 1 + .../app/timesafari/TimeSafariApplication.java | 41 ++ .../timesafari/TimeSafariNativeFetcher.java | 549 ++++++++++++++++++ 4 files changed, 593 insertions(+) create mode 100644 android/app/src/main/java/app/timesafari/TimeSafariApplication.java create mode 100644 android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java diff --git a/android/app/build.gradle b/android/app/build.gradle index edd4acd1..84ef47ae 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -101,6 +101,8 @@ dependencies { implementation project(':capacitor-android') implementation project(':capacitor-community-sqlite') implementation "androidx.biometric:biometric:1.2.0-alpha05" + // Gson for JSON parsing in native notification fetcher + implementation "com.google.code.gson:gson:2.10.1" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6392c86b..13c10268 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ Architecture Note: The JWT token is pre-generated in TypeScript using + * TimeSafari's {@code accessTokenForBackground()} function (ES256K DID-based signing). + * The native fetcher just uses the token directly - no JWT generation needed.

+ * + * @param apiBaseUrl Base URL for API server (e.g., "https://api.endorser.ch") + * @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; + + // Enhanced logging for JWT diagnostic purposes + Log.i(TAG, "TimeSafariNativeFetcher: Configured with API: " + apiBaseUrl); + if (activeDid != null) { + Log.i(TAG, "TimeSafariNativeFetcher: ActiveDID: " + activeDid.substring(0, Math.min(30, activeDid.length())) + + (activeDid.length() > 30 ? "..." : "")); + } else { + Log.w(TAG, "TimeSafariNativeFetcher: ActiveDID is NULL"); + } + + if (jwtToken != null) { + Log.i(TAG, "TimeSafariNativeFetcher: JWT token received - Length: " + jwtToken.length() + " chars"); + // Log first and last 10 chars for verification (not full token for security) + String tokenPreview = jwtToken.length() > 20 + ? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10) + : jwtToken.substring(0, Math.min(jwtToken.length(), 20)) + "..."; + Log.d(TAG, "TimeSafariNativeFetcher: JWT preview: " + tokenPreview); + } else { + Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL - API calls will fail"); + } + } + + @Override + @NonNull + public CompletableFuture> fetchContent( + @NonNull FetchContext context) { + + Log.d(TAG, "TimeSafariNativeFetcher: 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> fetchContentWithRetry( + @NonNull FetchContext context, int retryCount) { + + return CompletableFuture.supplyAsync(() -> { + try { + // Check if configured + if (apiBaseUrl == null || activeDid == null || jwtToken == null) { + Log.e(TAG, "TimeSafariNativeFetcher: Not configured. Call configureNativeFetcher() from TypeScript first."); + return Collections.emptyList(); + } + + Log.i(TAG, "TimeSafariNativeFetcher: Starting fetch from " + apiBaseUrl + ENDORSER_ENDPOINT); + + // Build request URL + String urlString = apiBaseUrl + ENDORSER_ENDPOINT; + URL url = new URL(urlString); + + // 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"); + + // Diagnostic logging for JWT usage + if (jwtToken != null) { + String jwtPreview = jwtToken.length() > 20 + ? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10) + : jwtToken; + Log.d(TAG, "TimeSafariNativeFetcher: Using JWT for API call - Length: " + jwtToken.length() + + ", Preview: " + jwtPreview + ", ActiveDID: " + + (activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null")); + } else { + Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL when making API call!"); + } + + connection.setRequestProperty("Authorization", "Bearer " + jwtToken); + connection.setDoOutput(true); + + // Build request body + Map requestBody = new HashMap<>(); + requestBody.put("planIds", getStarredPlanIds()); + + // afterId is required by the API endpoint + // Use "0" for first request (no previous data), or stored jwtId for subsequent requests + String afterId = getLastAcknowledgedJwtId(); + if (afterId == null || afterId.isEmpty()) { + afterId = "0"; // First request - start from beginning + } + requestBody.put("afterId", afterId); + + String jsonBody = gson.toJson(requestBody); + Log.d(TAG, "TimeSafariNativeFetcher: Request body: " + jsonBody); + + // Write request body + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + // Execute request + int responseCode = connection.getResponseCode(); + Log.d(TAG, "TimeSafariNativeFetcher: 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, "TimeSafariNativeFetcher: Response body length: " + responseBody.length()); + + // Parse response and convert to NotificationContent + List 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, "TimeSafariNativeFetcher: Successfully fetched " + contents.size() + + " notification(s)"); + + return contents; + + } else { + // Read error response + 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, "TimeSafariNativeFetcher: Could not read error stream", e); + } + + Log.e(TAG, "TimeSafariNativeFetcher: 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, "TimeSafariNativeFetcher: Retryable error, retrying in " + delayMs + "ms " + + "(" + (retryCount + 1) + "/" + MAX_RETRIES + ")"); + + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.e(TAG, "TimeSafariNativeFetcher: 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, "TimeSafariNativeFetcher: Non-retryable client error " + responseCode); + } else if (retryCount >= MAX_RETRIES) { + Log.e(TAG, "TimeSafariNativeFetcher: 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, "TimeSafariNativeFetcher: 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, "TimeSafariNativeFetcher: Retrying after network error in " + delayMs + "ms " + + "(" + (retryCount + 1) + "/" + MAX_RETRIES + ")"); + + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + Log.e(TAG, "TimeSafariNativeFetcher: Retry delay interrupted", ie); + return Collections.emptyList(); + } + + return fetchContentWithRetry(context, retryCount + 1).join(); + } + + Log.e(TAG, "TimeSafariNativeFetcher: Max retries reached for network error"); + return Collections.emptyList(); + + } catch (Exception e) { + Log.e(TAG, "TimeSafariNativeFetcher: Error during fetch", e); + // Non-retryable errors (parsing, configuration, etc.) + return Collections.emptyList(); + } + }); + } + + /** + * 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 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 getStarredPlanIds() { + try { + // Use the same SharedPreferences as the plugin (not the instance variable 'prefs') + // Plugin stores in "daily_notification_timesafari" with key "starredPlanIds" + SharedPreferences pluginPrefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String idsJson = pluginPrefs.getString(KEY_STARRED_PLAN_IDS, "[]"); + + if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) { + Log.d(TAG, "TimeSafariNativeFetcher: No starred plan IDs found in SharedPreferences"); + return new ArrayList<>(); + } + + // Parse JSON array (plugin stores as JSON string) + JsonParser parser = new JsonParser(); + JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray(); + List planIds = new ArrayList<>(); + + for (int i = 0; i < jsonArray.size(); i++) { + planIds.add(jsonArray.get(i).getAsString()); + } + + Log.i(TAG, "TimeSafariNativeFetcher: Loaded " + planIds.size() + " starred plan IDs from SharedPreferences"); + if (planIds.size() > 0) { + Log.d(TAG, "TimeSafariNativeFetcher: First plan ID: " + + planIds.get(0).substring(0, Math.min(30, planIds.get(0).length())) + "..."); + } + return planIds; + + } catch (Exception e) { + Log.e(TAG, "TimeSafariNativeFetcher: Error loading starred plan IDs from SharedPreferences", e); + return new ArrayList<>(); + } + } + + /** + * Get last acknowledged JWT ID from SharedPreferences (for pagination) + * + * @return Last acknowledged JWT ID, or null if none stored + */ + private String getLastAcknowledgedJwtId() { + try { + String jwtId = prefs.getString(KEY_LAST_ACKED_JWT_ID, null); + if (jwtId != null) { + Log.d(TAG, "TimeSafariNativeFetcher: Loaded last acknowledged JWT ID"); + } + return jwtId; + } catch (Exception e) { + Log.e(TAG, "TimeSafariNativeFetcher: Error loading last acknowledged JWT ID", e); + return null; + } + } + + /** + * 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 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, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID: " + + jwtId.substring(0, Math.min(20, jwtId.length())) + "..."); + } + } + } catch (Exception e) { + Log.w(TAG, "TimeSafariNativeFetcher: Could not extract JWT ID from response for pagination", e); + } + } + + /** + * Update last acknowledged JWT ID in SharedPreferences + * + * @param jwtId JWT ID to store as last acknowledged + */ + private void updateLastAckedJwtId(String jwtId) { + try { + prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply(); + Log.d(TAG, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID"); + } catch (Exception e) { + Log.e(TAG, "TimeSafariNativeFetcher: Error updating last acknowledged JWT ID", e); + } + } + + /** + * Parse API response and convert to NotificationContent list + */ + private List parseApiResponse(String responseBody, FetchContext context) { + List 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 + // 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 : + 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, "TimeSafariNativeFetcher: Error parsing API response", e); + // Return empty list on parse error + } + + return contents; + } +} +