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