make an attempt at new notifications using an API (fires always, can't turn off)

This commit is contained in:
2026-03-15 19:34:30 -06:00
parent c0678385df
commit 8ac6dd6ce0
9 changed files with 583 additions and 31 deletions

View File

@@ -70,6 +70,10 @@ public class MainActivity extends BridgeActivity {
// Register DailyNotification plugin
// Plugin is written in Kotlin but compiles to Java-compatible bytecode
registerPlugin(org.timesafari.dailynotification.DailyNotificationPlugin.class);
// Register native content fetcher for API-driven daily notifications (Endorser.ch)
org.timesafari.dailynotification.DailyNotificationPlugin.setNativeFetcher(
new TimeSafariNativeFetcher(this));
// Initialize SQLite
//registerPlugin(SQLite.class);

View File

@@ -1,31 +1,63 @@
package app.timesafari;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.timesafari.dailynotification.FetchContext;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent;
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 for API-driven daily notifications.
* Calls Endorser.ch plansLastUpdatedBetween with configured credentials and
* starred plan IDs (from plugin's updateStarredPlans), then returns notification content.
*/
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
private static final String TAG = "TimeSafariNativeFetcher";
private final Context context;
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int READ_TIMEOUT_MS = 15000;
private static final int MAX_RETRIES = 3;
private static final int RETRY_DELAY_MS = 1000;
// Must match plugin's SharedPreferences name and keys (DailyNotificationPlugin / TimeSafariIntegrationManager)
private static final String PREFS_NAME = "daily_notification_timesafari";
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds";
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id";
private final Gson gson = new Gson();
private final Context appContext;
private final SharedPreferences prefs;
// Configuration from TypeScript (set via configure())
private volatile String apiBaseUrl;
private volatile String activeDid;
private volatile String jwtToken;
public TimeSafariNativeFetcher(Context context) {
this.context = context;
this.appContext = context.getApplicationContext();
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
@Override
@@ -33,41 +65,187 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken;
Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl + ", DID: " + activeDid);
Log.i(TAG, "Configured with API: " + apiBaseUrl);
}
@NonNull
@Override
public CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext fetchContext) {
Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger);
return fetchContentWithRetry(fetchContext, 0);
}
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
@NonNull FetchContext context, int retryCount) {
return CompletableFuture.supplyAsync(() -> {
try {
// TODO: Implement actual content fetching for TimeSafari
// This should query the TimeSafari API for notification content
// using the configured apiBaseUrl, activeDid, and jwtToken
if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first.");
return Collections.emptyList();
}
// For now, return a placeholder notification
long scheduledTime = fetchContext.scheduledTime != null
? fetchContext.scheduledTime
: System.currentTimeMillis() + 60000; // 1 minute from now
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + jwtToken);
connection.setDoOutput(true);
NotificationContent content = new NotificationContent(
"TimeSafari Update",
"Check your starred projects for updates!",
scheduledTime
);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("planIds", getStarredPlanIds());
String afterId = getLastAcknowledgedJwtId();
if (afterId == null || afterId.isEmpty()) {
afterId = "0";
}
requestBody.put("afterId", afterId);
List<NotificationContent> results = new ArrayList<>();
results.add(content);
String jsonBody = gson.toJson(requestBody);
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
Log.d(TAG, "Returning " + results.size() + " notification(s)");
return results;
int responseCode = connection.getResponseCode();
Log.d(TAG, "HTTP response code: " + responseCode);
if (responseCode == 200) {
StringBuilder response = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
String responseBody = response.toString();
List<NotificationContent> contents = parseApiResponse(responseBody, context);
if (!contents.isEmpty()) {
updateLastAckedJwtIdFromResponse(responseBody);
}
Log.i(TAG, "Fetched " + contents.size() + " notification(s)");
return contents;
}
if (retryCount < MAX_RETRIES && (responseCode >= 500 || responseCode == 429)) {
int delayMs = RETRY_DELAY_MS * (1 << retryCount);
Log.w(TAG, "Retryable error " + responseCode + ", retrying in " + delayMs + "ms");
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Collections.emptyList();
}
return fetchContentWithRetry(context, retryCount + 1).join();
}
Log.e(TAG, "API error " + responseCode);
return Collections.emptyList();
} catch (Exception e) {
Log.e(TAG, "Fetch failed", e);
if (retryCount < MAX_RETRIES) {
try {
Thread.sleep(RETRY_DELAY_MS * (1 << retryCount));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return Collections.emptyList();
}
return fetchContentWithRetry(context, retryCount + 1).join();
}
return Collections.emptyList();
}
});
}
private List<String> getStarredPlanIds() {
try {
String idsJson = prefs.getString(KEY_STARRED_PLAN_IDS, "[]");
if (idsJson == null || idsJson.isEmpty() || "[]".equals(idsJson)) {
return new ArrayList<>();
}
JsonArray arr = JsonParser.parseString(idsJson).getAsJsonArray();
List<String> list = new ArrayList<>();
for (int i = 0; i < arr.size(); i++) {
list.add(arr.get(i).getAsString());
}
return list;
} catch (Exception e) {
Log.e(TAG, "Error loading starred plan IDs", e);
return new ArrayList<>();
}
}
private String getLastAcknowledgedJwtId() {
return prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
}
private void updateLastAckedJwtIdFromResponse(String responseBody) {
try {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
if (!root.has("data")) return;
JsonArray dataArray = root.getAsJsonArray("data");
if (dataArray == null || dataArray.size() == 0) return;
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
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()) {
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
}
} catch (Exception e) {
Log.w(TAG, "Could not extract JWT ID from response", e);
}
}
private List<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
List<NotificationContent> contents = new ArrayList<>();
try {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray dataArray = root.has("data") ? root.getAsJsonArray("data") : null;
if (dataArray != null) {
for (int i = 0; i < dataArray.size(); i++) {
JsonObject item = dataArray.get(i).getAsJsonObject();
NotificationContent content = new NotificationContent();
String planId = null;
String jwtId = null;
if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("handleId")) planId = plan.get("handleId").getAsString();
if (plan.has("jwtId")) jwtId = plan.get("jwtId").getAsString();
}
if (planId == null && item.has("planId")) planId = item.get("planId").getAsString();
if (jwtId == null && item.has("jwtId")) jwtId = item.get("jwtId").getAsString();
content.setId("endorser_" + (jwtId != null ? jwtId : (System.currentTimeMillis() + "_" + i)));
content.setTitle(planId != null ? "Update: " + planId.substring(Math.max(0, planId.length() - 8)) : "Project Update");
content.setBody(planId != null ? "Plan " + planId.substring(Math.max(0, planId.length() - 12)) + " has been updated." : "A project you follow has been updated.");
content.setScheduledTime(context.scheduledTime != null ? context.scheduledTime : (System.currentTimeMillis() + 3600000));
content.setPriority("default");
content.setSound(true);
contents.add(content);
}
}
if (contents.isEmpty()) {
NotificationContent defaultContent = new NotificationContent();
defaultContent.setId("endorser_no_updates_" + System.currentTimeMillis());
defaultContent.setTitle("No Project Updates");
defaultContent.setBody("No updates in your starred projects.");
defaultContent.setScheduledTime(context.scheduledTime != null ? context.scheduledTime : (System.currentTimeMillis() + 3600000));
defaultContent.setPriority("default");
defaultContent.setSound(true);
contents.add(defaultContent);
}
} catch (Exception e) {
Log.e(TAG, "Error parsing API response", e);
}
return contents;
}
}