feat(android): improve New Activity notification copy in TimeSafariNativeFetcher

Aggregate API rows into one notification with Starred Project Update(s) titles,
plan names in typographic quotes, and "+ N more have been updated." for multiples.
Stop emitting the empty-data "No Project Updates" fallback. Sync internal docs.
This commit is contained in:
Jose Olarte III
2026-03-31 19:50:14 +08:00
parent 230dc52974
commit 8ba84888ee
3 changed files with 62 additions and 38 deletions

View File

@@ -325,44 +325,68 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
}
}
/**
* Display title for a plansLastUpdatedBetween row; prefers {@code plan.name}, else "Unnamed Project".
*/
private String extractProjectDisplayTitle(JsonObject item) {
if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("name") && !plan.get("name").isJsonNull()) {
String name = plan.get("name").getAsString();
if (name != null && !name.trim().isEmpty()) {
return name.trim();
}
}
}
return "Unnamed Project";
}
@Nullable
private String extractJwtIdFromItem(JsonObject item) {
if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("jwtId") && !plan.get("jwtId").isJsonNull()) {
return plan.get("jwtId").getAsString();
}
}
if (item.has("jwtId") && !item.get("jwtId").isJsonNull()) {
return item.get("jwtId").getAsString();
}
return null;
}
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();
if (dataArray == null || dataArray.size() == 0) {
return contents;
}
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);
JsonObject firstItem = dataArray.get(0).getAsJsonObject();
String firstTitle = extractProjectDisplayTitle(firstItem);
String jwtId = extractJwtIdFromItem(firstItem);
NotificationContent content = new NotificationContent();
content.setId("endorser_" + (jwtId != null ? jwtId : ("batch_" + System.currentTimeMillis())));
int n = dataArray.size();
String quotedFirst = "\u201C" + firstTitle + "\u201D";
if (n == 1) {
content.setTitle("Starred Project Update");
content.setBody(quotedFirst + " has been updated.");
} else {
content.setTitle("Starred Project Updates");
int more = n - 1;
content.setBody(quotedFirst + " + " + more + " more have been updated.");
}
content.setScheduledTime(
context.scheduledTime != null
? context.scheduledTime
: (System.currentTimeMillis() + 3600000));
content.setPriority("default");
content.setSound(true);
contents.add(content);
} catch (Exception e) {
Log.e(TAG, "Error parsing API response", e);
}

View File

@@ -34,7 +34,7 @@ The app must:
- Called from `main.capacitor.ts` after the 2s delay (with deep link registration).
- Called from `AccountViewView.initializeState()` when on native and `activeDid` is set; when New Activity is enabled, also calls `updateStarredPlans(settings.starredPlanHandleIds)`.
- **Implement real API calls in Android native fetcher**
- `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher`: POST to `/api/v2/report/plansLastUpdatedBetween` with `planIds` (from SharedPreferences `daily_notification_timesafari` / `starredPlanIds`) and `afterId`; parses response into `NotificationContent` list; updates `last_acked_jwt_id` for pagination.
- `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher`: POST to `/api/v2/report/plansLastUpdatedBetween` with `planIds` (from SharedPreferences `daily_notification_timesafari` / `starredPlanIds`) and `afterId`; when `data` is non-empty, builds **one** aggregated `NotificationContent` (title **Starred Project Update** or **Starred Project Updates**, body from `plan.name` with typographic quotes, then `has been updated.` or `+ N more have been updated.`); when `data` is empty, returns an empty list (no “no updates” notification); updates `last_acked_jwt_id` for pagination when content is returned.
- Registered in `MainActivity.onCreate()` via `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`.
- **Sync starred plan IDs**
- Shared helper `syncStarredPlansToNativePlugin(planIds)` in `src/services/notifications/syncStarredPlansToNativePlugin.ts` (exported from `src/services/notifications/index.ts`) calls `DailyNotification.updateStarredPlans` on native only; ignores `UNIMPLEMENTED`.
@@ -71,7 +71,7 @@ Enable New Activity, set time, wait for prefetch and notification (or use a shor
- **Test full flow on iOS**
Same as Android: enable, set time, verify prefetch and notification delivery and content.
- **Test with no starred plans**
Enable New Activity with empty `starredPlanHandleIds`; confirm no crash and sensible fallback (e.g. “No updates in your starred projects” or similar).
Enable New Activity with empty `starredPlanHandleIds`; confirm no crash; the native fetcher returns no Endorser-derived items when there is nothing to query or no new rows (see `TimeSafariNativeFetcher`).
- **Test JWT expiry**
Ensure behavior when the token passed to `configureNativeFetcher` has expired (e.g. app in background for a long time); document or implement refresh (e.g. re-call `configureNativeFetcherIfReady` on foreground or when opening Account).

View File

@@ -193,7 +193,7 @@ Use this to verify that when a **starred** plan has **new** activity reported by
The steps and expected notification copy below are **Android-specific**: this repo registers `TimeSafariNativeFetcher` only on Android today. Do not assume the same strings or behavior on iOS until native fetcher parity exists; see **`doc/notification-from-api-call.md`** (iOS checklist and remaining tasks).
**How it works (short):** On Android, `TimeSafariNativeFetcher` POSTs to `/api/v2/report/plansLastUpdatedBetween` with `planIds` from the plugin (`updateStarredPlans`) and `afterId` from stored `last_acked_jwt_id` (or `"0"` initially). When the response `data` array is **non-empty**, each row becomes notification content with titles like `Update: <last 8 chars of handleId>` and bodies like `Plan <last 12 chars of handleId> has been updated.` When `data` is **empty**, the fetcher still supplies a single item: title **No Project Updates**, body **No updates in your starred projects.** (See `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`.)
**How it works (short):** On Android, `TimeSafariNativeFetcher` POSTs to `/api/v2/report/plansLastUpdatedBetween` with `planIds` from the plugin (`updateStarredPlans`) and `afterId` from stored `last_acked_jwt_id` (or `"0"` initially). When the response `data` array is **non-empty**, the fetcher builds **one** `NotificationContent`: title **Starred Project Update** (one row) or **Starred Project Updates** (two or more rows); body uses each rows `plan.name` when present (else **Unnamed Project**). For a single update: `[name] has been updated.` For multiple: typographic quotes around the first rows name, then ` + N more have been updated.` (with `N` = number of additional rows). When `data` is **empty**, the fetcher returns **no** notification items (no “nothing to report” notification). (See `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`.)
**Procedure (repeatable on device)**
@@ -202,15 +202,15 @@ The steps and expected notification copy below are **Android-specific**: this re
3. Turn **New Activity Notification** on and pick a time **25 minutes ahead** (same quick-test pattern as above).
4. Open **Account** once (or finish the enable flow) so `updateStarredPlans({ planIds })` runs with current `starredPlanHandleIds`.
5. **Background the app** (home out; do not force-quit). Prefetch runs on the cron **~5 minutes before** the chosen time; the user notification fires at the chosen time.
6. **Produce new activity the API will return:** before that prefetch window (i.e. early enough that the scheduled content fetch still sees it), make a real change to the starred plan so `plansLastUpdatedBetween` returns **new** rows after the current `afterId` (e.g. an edit or other update your backend exposes through that report). If you change the plan **after** prefetch already ran with no new rows, you may only see **No Project Updates** until the next prefetch cycle (typically the next day at the same T5 schedule, unless you reschedule).
6. **Produce new activity the API will return:** before that prefetch window (i.e. early enough that the scheduled content fetch still sees it), make a real change to the starred plan so `plansLastUpdatedBetween` returns **new** rows after the current `afterId` (e.g. an edit or other update your backend exposes through that report). If you change the plan **after** prefetch already ran with no new rows, you may not get an API-derived notification until the next prefetch cycle (typically the next day at the same T5 schedule, unless you reschedule).
**What to verify**
- **One notification** at the chosen time (no extra static “Daily Check-In” after the fix—see “What to verify (after fix)” above).
- **Success path (API returns updates):** Title/body match the **Update: …** / **Plan … has been updated.** pattern (truncated handle segments), not the generic `buildDualScheduleConfig` defaults (**New Activity** / **Check your starred projects and offers for updates.**), which apply when the plugin falls back—e.g. fetch failure—not when the Android fetcher successfully returns Endorser-parsed content.
- **Contrast (cursor caught up, no new rows):** After a successful fetch that returned data, `last_acked_jwt_id` advances. Without further plan changes, a later run may show **No Project Updates** / **No updates in your starred projects.**—useful to compare against the “has activity” case.
- **Success path (API returns updates):** Title/body match **Starred Project Update(s)** and the `[name] has been updated.` / `[first name] + N more have been updated.` patterns (names from `plan.name`), not the generic `buildDualScheduleConfig` defaults (**New Activity** / **Check your starred projects and offers for updates.**), which apply when the plugin falls back—e.g. fetch failure—not when the Android fetcher successfully returns Endorser-parsed content.
- **Contrast (cursor caught up, no new rows):** After a successful fetch that returned data, `last_acked_jwt_id` advances. Without further plan changes, a later prefetch may return an empty `data` array; the fetcher then supplies **no** Endorser-derived notification (useful to compare against the “has activity” case; the plugin may still show dual-schedule fallback text depending on configuration).
**Repeatability:** Each successful fetch that returns data moves the `afterId` cursor forward. To see **Update: …** again on subsequent tests, make **another** qualifying plan change (or accept heavier setup such as clearing app/plugin storage to reset cursor—usually unnecessary).
**Repeatability:** Each successful fetch that returns data moves the `afterId` cursor forward. To see **Starred Project Update** copy again on subsequent tests, make **another** qualifying plan change (or accept heavier setup such as clearing app/plugin storage to reset cursor—usually unnecessary).
**Debugging:** On Android, filter **logcat** for `TimeSafariNativeFetcher` (e.g. HTTP 200, `Fetched N notification(s)`) to confirm prefetch ran and how many `NotificationContent` items were built.