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