From 8ba84888eef24906594d376ce1608bb388bb7aeb Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Tue, 31 Mar 2026 19:50:14 +0800 Subject: [PATCH] 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. --- .../timesafari/TimeSafariNativeFetcher.java | 86 ++++++++++++------- doc/notification-from-api-call.md | 4 +- ...tification-new-activity-lay-of-the-land.md | 10 +-- 3 files changed, 62 insertions(+), 38 deletions(-) diff --git a/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java b/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java index 1deecbec..926ba6f5 100644 --- a/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java +++ b/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java @@ -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 parseApiResponse(String responseBody, FetchContext context) { List 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); } diff --git a/doc/notification-from-api-call.md b/doc/notification-from-api-call.md index 1d970ded..4eee6798 100644 --- a/doc/notification-from-api-call.md +++ b/doc/notification-from-api-call.md @@ -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). diff --git a/doc/notification-new-activity-lay-of-the-land.md b/doc/notification-new-activity-lay-of-the-land.md index e9963679..3d58a0bf 100644 --- a/doc/notification-new-activity-lay-of-the-land.md +++ b/doc/notification-new-activity-lay-of-the-land.md @@ -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: ` and bodies like `Plan 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 row’s `plan.name` when present (else **Unnamed Project**). For a single update: `[name] has been updated.` For multiple: typographic quotes around the first row’s 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 **2–5 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 T−5 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 T−5 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.