|
|
|
|
@@ -0,0 +1,549 @@
|
|
|
|
|
/**
|
|
|
|
|
* TimeSafariNativeFetcher.java
|
|
|
|
|
*
|
|
|
|
|
* Implementation of NativeNotificationContentFetcher for the TimeSafari app.
|
|
|
|
|
* Fetches notification content from the endorser API endpoint.
|
|
|
|
|
*
|
|
|
|
|
* @author TimeSafari Team
|
|
|
|
|
* @version 1.0.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
package app.timesafari;
|
|
|
|
|
|
|
|
|
|
import android.content.Context;
|
|
|
|
|
import android.content.SharedPreferences;
|
|
|
|
|
import android.util.Log;
|
|
|
|
|
import androidx.annotation.NonNull;
|
|
|
|
|
import com.timesafari.dailynotification.FetchContext;
|
|
|
|
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
|
|
|
|
import com.timesafari.dailynotification.NotificationContent;
|
|
|
|
|
import com.google.gson.Gson;
|
|
|
|
|
import com.google.gson.JsonObject;
|
|
|
|
|
import com.google.gson.JsonArray;
|
|
|
|
|
import com.google.gson.JsonParser;
|
|
|
|
|
import java.io.BufferedReader;
|
|
|
|
|
import java.io.InputStreamReader;
|
|
|
|
|
import java.io.OutputStream;
|
|
|
|
|
import java.net.HttpURLConnection;
|
|
|
|
|
import java.net.URL;
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
|
import java.util.ArrayList;
|
|
|
|
|
import java.util.Collections;
|
|
|
|
|
import java.util.HashMap;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.concurrent.CompletableFuture;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Native content fetcher implementation for TimeSafari
|
|
|
|
|
*
|
|
|
|
|
* Fetches notification content from the endorser API endpoint.
|
|
|
|
|
* Uses the same endpoint as the TypeScript code: /api/v2/report/plansLastUpdatedBetween
|
|
|
|
|
*/
|
|
|
|
|
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
|
|
|
|
|
|
|
|
|
private static final String TAG = "TimeSafariNativeFetcher";
|
|
|
|
|
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
|
|
|
|
|
private static final int CONNECT_TIMEOUT_MS = 10000; // 10 seconds
|
|
|
|
|
private static final int READ_TIMEOUT_MS = 15000; // 15 seconds
|
|
|
|
|
private static final int MAX_RETRIES = 3; // Maximum number of retry attempts
|
|
|
|
|
private static final int RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
|
|
|
|
|
|
|
|
|
|
// SharedPreferences constants
|
|
|
|
|
// NOTE: Must match plugin's SharedPreferences name and keys for starred plans
|
|
|
|
|
// Plugin uses "daily_notification_timesafari" (see DailyNotificationPlugin.updateStarredPlans)
|
|
|
|
|
private static final String PREFS_NAME = "daily_notification_timesafari";
|
|
|
|
|
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds"; // Matches plugin key
|
|
|
|
|
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id"; // For pagination
|
|
|
|
|
|
|
|
|
|
private final Gson gson = new Gson();
|
|
|
|
|
private final Context appContext;
|
|
|
|
|
private SharedPreferences prefs;
|
|
|
|
|
|
|
|
|
|
// Volatile fields for configuration, set via configure() method
|
|
|
|
|
private volatile String apiBaseUrl;
|
|
|
|
|
private volatile String activeDid;
|
|
|
|
|
private volatile String jwtToken; // Pre-generated JWT token from TypeScript (ES256K signed)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Constructor
|
|
|
|
|
*
|
|
|
|
|
* @param context Application context for SharedPreferences access
|
|
|
|
|
*/
|
|
|
|
|
public TimeSafariNativeFetcher(Context context) {
|
|
|
|
|
this.appContext = context.getApplicationContext();
|
|
|
|
|
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
|
|
|
Log.d(TAG, "TimeSafariNativeFetcher: Initialized with context");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Configure the native fetcher with API credentials
|
|
|
|
|
*
|
|
|
|
|
* Called by the plugin when configureNativeFetcher() is invoked from TypeScript.
|
|
|
|
|
* This method stores the configuration for use in background fetches.
|
|
|
|
|
*
|
|
|
|
|
* <p><b>Architecture Note:</b> The JWT token is pre-generated in TypeScript using
|
|
|
|
|
* TimeSafari's {@code accessTokenForBackground()} function (ES256K DID-based signing).
|
|
|
|
|
* The native fetcher just uses the token directly - no JWT generation needed.</p>
|
|
|
|
|
*
|
|
|
|
|
* @param apiBaseUrl Base URL for API server (e.g., "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<List<NotificationContent>> 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<List<NotificationContent>> 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<String, Object> 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<NotificationContent> contents = parseApiResponse(responseBody, context);
|
|
|
|
|
|
|
|
|
|
// Update last acknowledged JWT ID from the response (for pagination)
|
|
|
|
|
if (!contents.isEmpty()) {
|
|
|
|
|
// Get the last JWT ID from the parsed response (stored during parsing)
|
|
|
|
|
updateLastAckedJwtIdFromResponse(contents, responseBody);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Log.i(TAG, "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<String> 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<String> 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<NotificationContent> contents, String responseBody) {
|
|
|
|
|
try {
|
|
|
|
|
JsonParser parser = new JsonParser();
|
|
|
|
|
JsonObject root = parser.parse(responseBody).getAsJsonObject();
|
|
|
|
|
JsonArray dataArray = root.getAsJsonArray("data");
|
|
|
|
|
|
|
|
|
|
if (dataArray != null && dataArray.size() > 0) {
|
|
|
|
|
// Get the last item's JWT ID (most recent)
|
|
|
|
|
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
|
|
|
|
|
|
|
|
|
|
// Try to get JWT ID from different possible locations in response structure
|
|
|
|
|
String jwtId = null;
|
|
|
|
|
if (lastItem.has("jwtId")) {
|
|
|
|
|
jwtId = lastItem.get("jwtId").getAsString();
|
|
|
|
|
} else if (lastItem.has("plan")) {
|
|
|
|
|
JsonObject plan = lastItem.getAsJsonObject("plan");
|
|
|
|
|
if (plan.has("jwtId")) {
|
|
|
|
|
jwtId = plan.get("jwtId").getAsString();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (jwtId != null && !jwtId.isEmpty()) {
|
|
|
|
|
updateLastAckedJwtId(jwtId);
|
|
|
|
|
Log.d(TAG, "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<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
|
|
|
|
|
List<NotificationContent> 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|