Browse Source
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 Raymerpull/214/head
4 changed files with 593 additions and 0 deletions
@ -0,0 +1,41 @@ |
|||||
|
/** |
||||
|
* TimeSafariApplication.java |
||||
|
* |
||||
|
* Application class for the TimeSafari app. |
||||
|
* Registers the native content fetcher for the Daily Notification Plugin. |
||||
|
* |
||||
|
* @author TimeSafari Team |
||||
|
* @version 1.0.0 |
||||
|
*/ |
||||
|
|
||||
|
package app.timesafari; |
||||
|
|
||||
|
import android.app.Application; |
||||
|
import android.content.Context; |
||||
|
import android.util.Log; |
||||
|
import com.timesafari.dailynotification.DailyNotificationPlugin; |
||||
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher; |
||||
|
|
||||
|
/** |
||||
|
* Application class that registers native fetcher for daily notifications |
||||
|
*/ |
||||
|
public class TimeSafariApplication extends Application { |
||||
|
|
||||
|
private static final String TAG = "TimeSafariApplication"; |
||||
|
|
||||
|
@Override |
||||
|
public void onCreate() { |
||||
|
super.onCreate(); |
||||
|
|
||||
|
Log.i(TAG, "Initializing TimeSafari Application"); |
||||
|
|
||||
|
// Register native fetcher with application context
|
||||
|
Context context = getApplicationContext(); |
||||
|
NativeNotificationContentFetcher nativeFetcher = |
||||
|
new TimeSafariNativeFetcher(context); |
||||
|
DailyNotificationPlugin.setNativeFetcher(nativeFetcher); |
||||
|
|
||||
|
Log.i(TAG, "Native fetcher registered: " + nativeFetcher.getClass().getName()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
Loading…
Reference in new issue