diff --git a/doc/CHANGES/PLAN-device-alertsearch-notifications.md b/doc/CHANGES/PLAN-device-alertsearch-notifications.md new file mode 100644 index 00000000..5fa9dafc --- /dev/null +++ b/doc/CHANGES/PLAN-device-alertsearch-notifications.md @@ -0,0 +1,235 @@ +# Plan: On-Device New Activity Notifications Through alertSearch + +## Goal + +A person who picks on-device delivery for in-app New Activity notifications +(`newActivityDelivery: "device"`) gets the same count the server push would +send, at the same time, computed on the phone: + +1. The device runs the alertSearch query against both servers + (`/api/v2/report/alertSearch` on Endorser, `/api/partner/alertSearch` on + Partner) with the person's starred projects and search area. +2. It counts from the same start as the New Activity screen, and it moves that + start forward the way the notification service does, so a notification + never repeats what the person already saw and never skips what they have + not. +3. The notification reads "You have N new updates." and opens New Activity + with the run's start cursors as `c`, exactly like a server push. +4. The notification service holds nothing for this person. +5. A change to those APIs, to what counts, or to the notification text is a + change to configuration that the app's TypeScript sends. It needs no + plugin release and no Java or Swift change. + +## Current state + +- `TimeSafariNativeFetcher` (`android/app/src/main/java/app/timesafari/`, + `ios/App/App/`) is app code, not plugin code. The daily-notification-plugin + calls it five minutes before the notify time (`buildDualScheduleConfig`, + chained dual in plugin 3.x), and what it returns becomes the notification. +- The fetcher posts to Endorser `plansLastUpdatedBetween` with the starred plan + IDs, so it reports starred-project changes only: roughly the server's + `trackedPlanUpdates` list, one of eight. +- Its inputs come from `configureNativeFetcher` (API base URL, active DID, a + pool of day-scoped JWTs) and `updateStarredPlans` (plan IDs in plugin + storage: SharedPreferences on Android, UserDefaults on iOS). +- Every change to what the fetcher asks or counts is a Java and a Swift change. +- The read state lives in the app's SQLite settings (`newActivityCursors`, + `src/libs/newActivity.ts`), which native code does not read. + +## Design + +The plugin becomes a scheduled request runner that knows nothing about +alertSearch. It does four things: run a job on its schedule, hold the JWTs +that authorize the job's requests, execute the requests a job describes, and +keep the raw results for the app to read. `TimeSafariNativeFetcher` is retired. + +A notification has to be decided when the app is not running, so no +TypeScript runs at that moment. The plugin therefore interprets responses, but +only through a small, fixed vocabulary that the job supplies as data: where +the cursor is, whether there is another page, what to count, and what to say. + +### The job + +JS registers a job with one call (`configureRequestJob`), and replaces it the +same way. Shape, with the alertSearch values: + +```json +{ + "schemaVersion": 1, + "id": "newActivity", + "schedule": { "notifyCron": "0 9 * * *", "fetchLeadMinutes": 5 }, + "allowedHosts": ["api.endorser.ch", "partner-api.endorser.ch"], + "requests": [ + { + "id": "endorser", + "method": "POST", + "url": "https://api.endorser.ch/api/v2/report/alertSearch", + "body": { "planHandleIds": ["…"], "location": { "minLocLat": 40 } }, + "auth": { "jwtPool": "endorser" }, + "cursor": { + "value": "48213", + "requestField": "afterCursor", + "responseField": "nextAfterCursor" + }, + "paging": { "hasMoreField": "hasMore", "maxPages": 20 }, + "count": [ + { "path": "data.claims" }, + { "path": "data.personalPlanContributions" }, + { "path": "data.trackedPlanUpdates" }, + { "path": "data.trackedPlanClaims" }, + { "path": "data.plansNearby" } + ] + }, + { + "id": "partner", + "method": "POST", + "url": "https://partner-api.endorser.ch/api/partner/alertSearch", + "body": { "location": { "minLocLat": 40 } }, + "auth": { "jwtPool": "endorser" }, + "cursor": { + "value": "912-48213", + "requestField": "afterCursor", + "responseField": "nextAfterCursor" + }, + "paging": { "hasMoreField": "hasMore", "maxPages": 20 }, + "count": [ + { "path": "data.profilesNearby" }, + { "path": "data.profilesSimilar", "distinctBy": "rowId" }, + { "path": "data.plansSimilar", "distinctBy": "handleId" } + ] + } + ], + "commit": "allComplete", + "notification": { + "title": "TimeSafari", + "body": { "one": "You have 1 new update.", "other": "You have {count} new updates." }, + "skipWhenZero": true, + "deepLink": "https://giftopia.me/deep-link/new-activity?c={start.endorser}.{start.partner}" + } +} +``` + +What the plugin does with it on each scheduled run: + +- **Requests.** It runs each request from its stored cursor, putting the + cursor in the body under `requestField`, and pages while the response's + `hasMoreField` is true, up to `maxPages`, passing each page's + `responseField` as the next cursor. +- **Seeding.** A request with no cursor value makes one call, keeps only the + returned cursor, and counts nothing. +- **Counting.** The count is the number of array items at each `count` path + across all pages, distinct by `distinctBy` where given, summed over requests. +- **Commit.** With `allComplete`, the cursors advance only when every request + completed. Otherwise they all stay, and nothing is shown, so a source that + times out skips the day and the next run covers the whole window. +- **Notification.** It renders the `body` template with the count and the + run's start cursors, and shows nothing for zero when `skipWhenZero` is set. +- **Hosts.** It sends nothing to a host outside `allowedHosts`, so stored JWTs + cannot be pointed elsewhere. +- **Versions.** It refuses a job whose `schemaVersion` it does not know. Only a + new word in the vocabulary, such as a new way to count, needs a plugin + release. + +### Credentials + +The plugin holds JWT pools by name (`jwtPool`), each a set of day-scoped +delegated JWTs (`BACKGROUND_JWT_SECONDS_PER_DAY`, +the pool `configureNativeFetcher` passes), and picks one valid at request time. +JS refills a pool whenever it configures the job. The pools live in the +platform's protected store (Android Keystore-backed storage, iOS Keychain), +not in SharedPreferences or UserDefaults. + +### Results + +The plugin keeps the last runs of each job, pruned to a few: + +```json +{ + "jobId": "newActivity", + "ranAt": "2026-10-26T15:00:12Z", + "completed": true, + "notified": true, + "count": 4, + "start": { "endorser": "48213", "partner": "912-48213" }, + "next": { "endorser": "48301", "partner": "930-48301" }, + "responses": { "endorser": [{ "…": "raw page JSON" }], "partner": [] } +} +``` + +JS reads them with `getRequestJobResults` and interprets them with the code it +already has (`src/libs/alertSearch.ts`, `src/services/newActivity.ts`). A shape +change on a server is then handled in TypeScript alone. The raw pages also let +New Activity show what the last run found without asking the servers again. + +### State handoff + +JS stays the owner of the read state: + +- It sends the cursors in the job at the points where the push channel + uploads: turning on (latest cursors, `newActivityStartState`), a new time + (cursors unchanged), and the re-upload after a New Activity visit + (`refreshNotificationAuthorizations`, where `"device"` becomes a destination + next to `fcm` and `sms`). +- It records the cursors it sent as `uploaded.device` in + `NewActivityCursorState`, so the visit logic that skips an unchanged channel + works unchanged. +- When New Activity loads, it reads the latest result's `next` cursors, the + same way it treats the service as having moved on after a run. + +### One count definition + +The `count` paths and `distinctBy` keys in the job are the device's definition +of the count. They come from the same TypeScript the app uses for its own +counts (`NEW_ACTIVITY_BUCKETS`, the Partner deduplication by `rowId` and +`handleId`), so the device and the screen cannot disagree. A Jest test builds +the job and checks it against the notification service's +`ALERT_SEARCH_DIGEST_BUCKETS` fixtures. The count logic exists only in +TypeScript, not in Java or Swift. + +### Other notes + +- A source that times out should skip the day. + +- The plugin keeps no daily cap: its schedule fires once a day, as the + service's stored hour does on its side. A second notification the same day + takes a new time later that day, which is the person's own deliberate + reset, with no server or messaging cost for a cap to guard. + +- The Daily Reminder keeps its own schedule and is untouched. + +## Work + +### daily-notification-plugin + +- [ ] `configureRequestJob` / `cancelRequestJob` (Android and iOS): store the + job, schedule the fetch at `fetchLeadMinutes` before `notifyCron`. +- [ ] Request runner: cursor in body, paging, seeding, `allComplete` commit, + host allowlist, `schemaVersion` check. +- [ ] Counting by path with `distinctBy`; notification template with `one` / + `other`, `{count}`, `{start.}`; `skipWhenZero`; deep link. +- [ ] Named JWT pools in protected storage; pick a JWT valid at request time. +- [ ] `getRequestJobResults`, keeping and pruning the last runs. +- [ ] Plugin tests: a job run against a stub HTTP server, on both platforms. +- [ ] Keep `configureNativeFetcher` / `updateStarredPlans` until this app no + longer calls them, then remove them. +- [ ] Release, then bump the dependency here. + +### This app, TypeScript + +- [ ] Build the job from settings: server URLs, starred IDs, search area, + cursors, bucket paths, time, text. +- [ ] Configure it on turn-on, a new time, and the visit refresh in device + mode; refill the JWT pool each time; record `uploaded.device`. +- [ ] Read results on New Activity load and take their `next` cursors. +- [ ] Switch device mode from `scheduleDualNotification` to the job, and + cancel it on turn-off and on a switch to server mode. +- [ ] Drop "For now it checks only your starred projects." from the delivery + choice in `AccountViewView.vue`. +- [ ] Jest tests: the job's count definition against the service fixtures, and + results read back into `NewActivityCursorState`. + +### This app, native + +- [ ] Remove `TimeSafariNativeFetcher.java` and `.swift`, and their + registration in `MainActivity` and `AppDelegate`, once device mode runs + on the job. diff --git a/doc/CHANGES/PLAN.md b/doc/CHANGES/PLAN.md index 83c0c59c..94b39fdb 100644 --- a/doc/CHANGES/PLAN.md +++ b/doc/CHANGES/PLAN.md @@ -1,6 +1,7 @@ ## Tasks +- [ ] On-device New Activity notifications through alertSearch — next: plugin request-job runner (`configureRequestJob`), then the app's job builder; see [PLAN-device-alertsearch-notifications.md](PLAN-device-alertsearch-notifications.md) (Trent) - [ ] Default `apiServer` in every settings fallback. `$accountSettings()` (`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often `{}`) on its early-return and error paths, so `apiServer` can be undefined; diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index e604c0e4..4f19e8cc 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -306,6 +306,14 @@ const MIGRATIONS = [ ALTER TABLE settings ADD COLUMN newActivityCursors TEXT; `, }, + { + name: "013_add_newActivityDelivery_to_settings", + sql: ` + -- How in-app New Activity notifications arrive: 'server' (push from the + -- notification service, the default when blank) or 'device' + ALTER TABLE settings ADD COLUMN newActivityDelivery TEXT; + `, + }, ]; /** diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 24bd59bc..99ce07f5 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -47,6 +47,8 @@ export type Settings = { // JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen newActivityCursors?: string; + // how in-app New Activity notifications arrive: "server" (push; the default when unset) or "device" (on-device schedule) + newActivityDelivery?: string; // The claim list has a most recent one used in notifications that's separate from the last viewed lastNotifiedClaimId?: string; diff --git a/src/interfaces/accountView.ts b/src/interfaces/accountView.ts index 7c7f4bb1..9a8271db 100644 --- a/src/interfaces/accountView.ts +++ b/src/interfaces/accountView.ts @@ -30,6 +30,7 @@ export interface AccountSettings { name: string; bbox: BoundingBox; }>; + newActivityDelivery?: string; notifyingNewActivityTime?: string; notifyingNewActivitySmsPhone?: string; notifyingNewActivitySmsTime?: string; diff --git a/src/interfaces/notifyApi.ts b/src/interfaces/notifyApi.ts index db839dc4..82d94370 100644 --- a/src/interfaces/notifyApi.ts +++ b/src/interfaces/notifyApi.ts @@ -195,8 +195,8 @@ export interface AlertAuthorizationRequestBody { /** * True stores the batch's JWT for a UTC day that already ran, so a reset can * fire again today; absent skips such days. Sent by deliberate resets, not by - * the automatic re-upload after a New Activity visit. The push route ignores - * it. + * the automatic re-upload after a New Activity visit. Each route honors it + * only while today's alert can still go out. */ rerunToday?: boolean; } diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index e6e45702..ff3f50ef 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -50,6 +50,13 @@ import { onNotificationAuthMayBeReady, runLegacyApiNotificationsCleanupOnce, } from "@/services/notifications"; +import { + effectiveNewActivityDelivery, + storedNewActivityDelivery, +} from "@/services/notifications/newActivityDelivery"; +import { serverPushSupported } from "@/services/notifications/firebaseMessagingClient"; +import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { retrieveSettingsForActiveAccount } from "@/db/databaseUtil"; logger.log("[Capacitor] 🚀 Starting initialization"); logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); @@ -364,8 +371,27 @@ setTimeout(async () => { logger.info(`[Main] 🎉 Deep link system fully initialized!`); // One-time: remove persisted legacy api_* schedules from pre-retirement installs await runLegacyApiNotificationsCleanupOnce(); - // Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners) - await initializeNativePushAndFirebaseMessaging(); + // Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, + // delivery listeners). Skipped for on-device New Activity delivery, so the + // device is not registered with the notification service. + const settings = await retrieveSettingsForActiveAccount(); + if (storedNewActivityDelivery(settings) === "server") { + await initializeNativePushAndFirebaseMessaging(); + } + // Exactly one New Activity notifier runs. Installs from before the + // delivery choice also have the on-device schedule; drop it where the + // server push is the one that runs. + if ( + settings.notifyingNewActivityTime && + effectiveNewActivityDelivery(settings, await serverPushSupported()) === + "server" + ) { + try { + await DailyNotification.cancelDualSchedule?.(); + } catch (error) { + logger.warn("[Main] cancelDualSchedule failed:", error); + } + } // Configure native fetcher for API-driven daily notifications (activeDid + JWT) await configureNativeFetcherIfReady(); } catch (error) { diff --git a/src/services/notifications/NotificationService.ts b/src/services/notifications/NotificationService.ts index 95436334..eefcfd65 100644 --- a/src/services/notifications/NotificationService.ts +++ b/src/services/notifications/NotificationService.ts @@ -80,6 +80,30 @@ export async function registerToken(fcmToken: string): Promise { } } +/** + * Remove every device registration the notification service holds for the + * active DID, for someone who chose on-device New Activity notifications. + * Throws when the service refuses or cannot be reached. + */ +export async function unregisterDevicesFromNotificationApi(): Promise { + const auth = await getNotificationApiHeaders(); + if (!auth.ok) { + throw new Error(`unregister auth unavailable: ${auth.message}`); + } + const res = await fetch( + `${getNotificationApiBaseUrl()}/notifications/register`, + { method: "DELETE", headers: auth.headers }, + ); + if (!res.ok) { + throw new Error( + notificationApiFailureMessage( + res.status, + await readNotificationApiBody(res), + ), + ); + } +} + /** * Options for scheduling a daily notification */ diff --git a/src/services/notifications/alertAuthorization.test.ts b/src/services/notifications/alertAuthorization.test.ts index f8c34f89..24db1546 100644 --- a/src/services/notifications/alertAuthorization.test.ts +++ b/src/services/notifications/alertAuthorization.test.ts @@ -16,6 +16,8 @@ jest.mock("@/libs/crypto/vc", () => ({ jest.mock("./alertAuthorizationBatch", () => ({ ALERT_AUTHORIZATION_BATCH_DAYS: 100, mintAlertAuthorizationBatch: jest.fn(), + localTimeTextToUtc: (text: string) => + text === "8:30 PM" ? { notifyHourUtc: 2, notifyMinuteUtc: 30 } : null, })); jest.mock("./NotificationDebugConfig", () => ({ getNotificationApiBaseUrl: jest.fn(), @@ -145,6 +147,43 @@ describe("uploadAlertSearchAuthorization", () => { }); }); + it("sends the picked time, and rerunToday only for a reset", async () => { + const { dependencies, fetchMock } = createDependencies(); + await uploadAlertSearchAuthorization( + dependencies, + { endorserAfterCursor: "100" }, + { timeText: "8:30 PM", rerunToday: true }, + ); + const reset = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + expect(reset.notifyHourUtc).toBe(2); + expect(reset.notifyMinuteUtc).toBe(30); + expect(reset.rerunToday).toBe(true); + expect(reset.endorserAfterCursor).toBe("100"); + + await uploadAlertSearchAuthorization(dependencies, undefined, { + timeText: "8:30 PM", + }); + const refresh = JSON.parse(String(fetchMock.mock.calls[1][1].body)); + expect(refresh.notifyHourUtc).toBe(2); + expect(refresh).not.toHaveProperty("rerunToday"); + }); + + it("does not submit an unreadable time", async () => { + const { dependencies, fetchMock } = createDependencies(); + const result = await uploadAlertSearchAuthorization( + dependencies, + undefined, + { + timeText: "whenever", + }, + ); + expect(result).toEqual({ + ok: false, + errorMessage: "Unreadable notification time: whenever", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("does not log delegated JWT contents", async () => { const logSpy = jest.spyOn(console, "log").mockImplementation(); const warnSpy = jest.spyOn(console, "warn").mockImplementation(); diff --git a/src/services/notifications/alertAuthorization.ts b/src/services/notifications/alertAuthorization.ts index 6115377a..d3aa5c56 100644 --- a/src/services/notifications/alertAuthorization.ts +++ b/src/services/notifications/alertAuthorization.ts @@ -2,10 +2,12 @@ * Push-channel AlertSearch authorization against notification-wakeup-service. * * Upload mints a delegated JWT batch for the active identity and - * `POST /notifications/alert-authorization`, with the send time fixed at - * 09:00 in the device's zone. Revoke sends `DELETE` on that same route and - * does not unregister the FCM device. The debug panel uploads manually; the - * in-app New Activity toggle uploads on enable and revokes on disable. + * `POST /notifications/alert-authorization`, with the send time the person + * picked for in-app New Activity notifications, converted to UTC. Without a + * time (the debug panel) it sends 09:00 in the device's zone. Revoke sends + * `DELETE` on that same route and does not unregister the FCM device. The + * in-app New Activity toggle uploads on enable and on a new time, and revokes + * on disable. * * The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and * both mint with `mintAlertAuthorizationBatch`. The wire shapes are in @@ -20,6 +22,7 @@ import { ETHR_DID_PREFIX } from "@/libs/crypto/vc"; import { ALERT_AUTHORIZATION_BATCH_DAYS, type AlertAuthorizationBatch, + localTimeTextToUtc, mintAlertAuthorizationBatch, } from "./alertAuthorizationBatch"; import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime"; @@ -70,7 +73,7 @@ export interface AlertAuthorizationDependencies { getActiveDid: () => Promise; isAuthBypassEnabled: () => boolean; mintBatch: (did: string) => Promise; - /** IANA zone whose 09:00 becomes the batch's UTC send time. */ + /** IANA zone sent with the batch; its 09:00 is the default send time. */ getTimeZone: () => string; getAuthHeaders: typeof getNotificationApiHeaders; getBaseUrl: () => string; @@ -87,10 +90,25 @@ const defaultDependencies: AlertAuthorizationDependencies = { fetch: (input, init) => globalThis.fetch(input, init), }; +/** How an upload schedules the batch. */ +export interface AlertAuthorizationUploadOptions { + /** + * 12-hour local reading from the time picker, such as "8:30 PM". Absent: + * 09:00 in the device's zone. + */ + timeText?: string; + /** + * A deliberate reset (turning the channel on, a new time): today's run may + * go again even after it ran. Left out of the re-upload after a visit. + */ + rerunToday?: boolean; +} + function buildRequestBody( batch: AlertAuthorizationBatch, timeZone: string, searchState: AlertSearchUploadState = {}, + options: AlertAuthorizationUploadOptions = {}, ): AlertAuthorizationRequestBody & AlertSearchUploadState { if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) { throw new Error( @@ -98,7 +116,13 @@ function buildRequestBody( ); } - const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(timeZone); + const notifyTime = + options.timeText === undefined + ? alertSearchNotifyTimeUtcFromLocalNineAm(timeZone) + : localTimeTextToUtc(options.timeText); + if (!notifyTime) { + throw new Error("Unreadable notification time: " + options.timeText); + } return { batchId: batch.batchId, @@ -107,6 +131,7 @@ function buildRequestBody( timezone: timeZone, jwts: batch.jwts, ...searchState, + ...(options.rerunToday ? { rerunToday: true } : {}), }; } @@ -211,10 +236,12 @@ async function authorizationHttpFailure(response: Response): Promise<{ * * @param searchState cursors to count from, and the starred handle IDs and * area the daily search uses; see {@link AlertSearchUploadState} + * @param options the send time and whether this is a reset */ export async function uploadAlertSearchAuthorization( dependencies: AlertAuthorizationDependencies = defaultDependencies, searchState?: AlertSearchUploadState, + options?: AlertAuthorizationUploadOptions, ): Promise { try { const access = await requireDelegatedAlertAccess(dependencies, "uploaded"); @@ -224,7 +251,7 @@ export async function uploadAlertSearchAuthorization( const batch = await dependencies.mintBatch(access.did); const timeZone = dependencies.getTimeZone(); - const body = buildRequestBody(batch, timeZone, searchState); + const body = buildRequestBody(batch, timeZone, searchState, options); const response = await dependencies.fetch( `${dependencies.getBaseUrl()}/notifications/alert-authorization`, diff --git a/src/services/notifications/firebaseMessagingClient.ts b/src/services/notifications/firebaseMessagingClient.ts index 7e043331..4bac1421 100644 --- a/src/services/notifications/firebaseMessagingClient.ts +++ b/src/services/notifications/firebaseMessagingClient.ts @@ -74,7 +74,7 @@ async function registerRetrievedToken( * config, and register() there throws natively, crashing the app before any * JS catch can run. */ -async function canRegisterNativePush(): Promise { +export async function canRegisterNativePush(): Promise { if (Capacitor.getPlatform() !== "android") { return true; } @@ -87,6 +87,15 @@ async function canRegisterNativePush(): Promise { } } +/** + * Whether this build can receive New Activity server pushes: a native app + * whose build includes Firebase. iOS builds are assumed to; the FOSS opt-out + * is detected on Android only. + */ +export async function serverPushSupported(): Promise { + return Capacitor.isNativePlatform() && (await canRegisterNativePush()); +} + /** Most recent FCM token from native/web push registration (for debug UI). */ export function getLastKnownFcmToken(): string | null { return lastSeenFcmToken ?? lastRegisteredFcmToken; diff --git a/src/services/notifications/newActivityDelivery.ts b/src/services/notifications/newActivityDelivery.ts new file mode 100644 index 00000000..7f926753 --- /dev/null +++ b/src/services/notifications/newActivityDelivery.ts @@ -0,0 +1,30 @@ +/** + * How in-app New Activity notifications arrive. + * + * - "server": the notification service runs the daily alertSearch with the + * person's delegated JWTs and pushes the count through FCM. The default. + * - "device": the daily-notification-plugin fetches and notifies on the + * device. The notification service holds no JWTs, cursors, search area or + * device registration for the person. + * + * Exactly one of them runs. A build without Firebase (FOSS / F-Droid) can + * only use "device"; `serverPushSupported` in firebaseMessagingClient says + * which build this is. + */ + +export type NewActivityDelivery = "server" | "device"; + +/** The stored choice, with anything unset or unknown meaning the default. */ +export function storedNewActivityDelivery(settings: { + newActivityDelivery?: string; +}): NewActivityDelivery { + return settings.newActivityDelivery === "device" ? "device" : "server"; +} + +/** The delivery that actually runs: "device" wherever push is unavailable. */ +export function effectiveNewActivityDelivery( + settings: { newActivityDelivery?: string }, + serverPushSupported: boolean, +): NewActivityDelivery { + return serverPushSupported ? storedNewActivityDelivery(settings) : "device"; +} diff --git a/src/services/notifications/newActivityInAppSubscription.ts b/src/services/notifications/newActivityInAppSubscription.ts index 36794d6d..a5ce7640 100644 --- a/src/services/notifications/newActivityInAppSubscription.ts +++ b/src/services/notifications/newActivityInAppSubscription.ts @@ -12,23 +12,26 @@ import type { AlertSearchUploadState } from "@/interfaces/notifyApi"; import { type AlertAuthorizationDependencies, type AlertAuthorizationRevokeResult, + type AlertAuthorizationUploadOptions, type AlertAuthorizationUploadResult, revokeAlertSearchAuthorization, uploadAlertSearchAuthorization, } from "./alertAuthorization"; /** - * Upload the AlertSearch batch after the in-app setting has been enabled. - * A failure is reported and returned; it does not throw. + * Upload the AlertSearch batch when the in-app setting is enabled or given a + * new time. A failure is reported and returned; it does not throw. */ export async function authorizeInAppNewActivity( reportError: (message: string) => void, dependencies?: AlertAuthorizationDependencies, searchState?: AlertSearchUploadState, + options?: AlertAuthorizationUploadOptions, ): Promise { const result = await uploadAlertSearchAuthorization( dependencies, searchState, + options, ); if (!result.ok) { reportError(result.errorMessage); diff --git a/src/services/notifications/newActivityRefresh.ts b/src/services/notifications/newActivityRefresh.ts index fceaf1ad..4a4c8bd9 100644 --- a/src/services/notifications/newActivityRefresh.ts +++ b/src/services/notifications/newActivityRefresh.ts @@ -14,9 +14,11 @@ import { logger } from "@/utils/logger"; import { localTimeTextToUtc } from "./alertAuthorizationBatch"; import { uploadAlertSearchAuthorization } from "./alertAuthorization"; import { authorizeSmsAlertSearch } from "./smsNotificationApi"; +import { storedNewActivityDelivery } from "./newActivityDelivery"; /** The settings that say which channels are on. */ export interface NotificationChannelSettings { + newActivityDelivery?: string; notifyingNewActivityTime?: string; notifyingNewActivitySmsTime?: string; smsNotificationsEnabled?: boolean; @@ -26,7 +28,13 @@ export function enabledNotificationChannels( settings: NotificationChannelSettings, ): NotificationChannel[] { const channels: NotificationChannel[] = []; - if (settings.notifyingNewActivityTime) channels.push("fcm"); + // on-device delivery keeps nothing on the service to refresh + if ( + settings.notifyingNewActivityTime && + storedNewActivityDelivery(settings) === "server" + ) { + channels.push("fcm"); + } if ( settings.smsNotificationsEnabled && settings.notifyingNewActivitySmsTime @@ -37,7 +45,10 @@ export function enabledNotificationChannels( } export interface NotificationRefreshDependencies { - uploadFcm: (state: AlertSearchUploadState) => Promise; + uploadFcm: ( + state: AlertSearchUploadState, + timeText: string, + ) => Promise; uploadSms: ( did: string, notifyHourUtc: number, @@ -47,8 +58,10 @@ export interface NotificationRefreshDependencies { } const defaultDependencies: NotificationRefreshDependencies = { - uploadFcm: async (state) => { - const result = await uploadAlertSearchAuthorization(undefined, state); + uploadFcm: async (state, timeText) => { + const result = await uploadAlertSearchAuthorization(undefined, state, { + timeText, + }); if (!result.ok) { logger.warn( "[NewActivityRefresh] Push re-authorization failed:", @@ -82,7 +95,15 @@ export async function refreshNotificationAuthorizations( try { let ok = false; if (channel === "fcm") { - ok = await dependencies.uploadFcm(state); + const timeText = settings.notifyingNewActivityTime || ""; + if (!localTimeTextToUtc(timeText)) { + logger.warn( + "[NewActivityRefresh] Unreadable push time; push not re-authorized:", + settings.notifyingNewActivityTime, + ); + continue; + } + ok = await dependencies.uploadFcm(state, timeText); } else { const utc = localTimeTextToUtc( settings.notifyingNewActivitySmsTime || "", diff --git a/src/test/newActivity.test.ts b/src/test/newActivity.test.ts index f3379bd6..60e89b1e 100644 --- a/src/test/newActivity.test.ts +++ b/src/test/newActivity.test.ts @@ -628,6 +628,15 @@ describe("notification refresh", () => { ).toEqual(["sms"]); }); + it("leaves out push refreshes for on-device delivery", () => { + expect( + enabledNotificationChannels({ + notifyingNewActivityTime: "9:00 AM", + newActivityDelivery: "device", + }), + ).toEqual([]); + }); + it("uploads each channel with the cursors and reports the ones that took", async () => { const state = alertSearchUploadState( { planHandleIds: ["h1"], location: undefined }, @@ -643,21 +652,44 @@ describe("notification refresh", () => { const uploadSms = jest.fn().mockResolvedValue(true); const uploaded = await refreshNotificationAuthorizations( "did:ethr:0xme", - { notifyingNewActivitySmsTime: "9:00 AM" }, + { + notifyingNewActivityTime: "9:00 AM", + notifyingNewActivitySmsTime: "9:00 AM", + }, ["fcm", "sms"], cursors, state, { uploadFcm, uploadSms }, ); - expect(uploadFcm).toHaveBeenCalledWith(state); + // the push re-upload keeps the person's own time + expect(uploadFcm).toHaveBeenCalledWith(state, "9:00 AM"); expect(uploadSms).toHaveBeenCalledWith("did:ethr:0xme", 15, 0, state); expect(uploaded).toEqual({ sms: cursors }); }); - it("skips texts with an unreadable time and survives a thrown upload", async () => { + it("skips a channel with an unreadable time", async () => { + const uploadFcm = jest.fn(); + const uploadSms = jest.fn(); const uploaded = await refreshNotificationAuthorizations( "did:ethr:0xme", - { notifyingNewActivitySmsTime: "whenever" }, + { + notifyingNewActivityTime: "sometime", + notifyingNewActivitySmsTime: "whenever", + }, + ["fcm", "sms"], + cursors, + {}, + { uploadFcm, uploadSms }, + ); + expect(uploadFcm).not.toHaveBeenCalled(); + expect(uploadSms).not.toHaveBeenCalled(); + expect(uploaded).toEqual({}); + }); + + it("survives a thrown upload", async () => { + const uploaded = await refreshNotificationAuthorizations( + "did:ethr:0xme", + { notifyingNewActivityTime: "9:00 AM" }, ["fcm", "sms"], cursors, {}, diff --git a/src/test/newActivityDelivery.test.ts b/src/test/newActivityDelivery.test.ts new file mode 100644 index 00000000..542836cf --- /dev/null +++ b/src/test/newActivityDelivery.test.ts @@ -0,0 +1,24 @@ +import { + effectiveNewActivityDelivery, + storedNewActivityDelivery, +} from "@/services/notifications/newActivityDelivery"; + +describe("New Activity delivery", () => { + it("defaults to the server push", () => { + expect(storedNewActivityDelivery({})).toBe("server"); + expect(storedNewActivityDelivery({ newActivityDelivery: "bogus" })).toBe( + "server", + ); + expect(storedNewActivityDelivery({ newActivityDelivery: "device" })).toBe( + "device", + ); + }); + + it("runs on the device wherever push is unavailable", () => { + expect(effectiveNewActivityDelivery({}, true)).toBe("server"); + expect(effectiveNewActivityDelivery({}, false)).toBe("device"); + expect( + effectiveNewActivityDelivery({ newActivityDelivery: "device" }, true), + ).toBe("device"); + }); +}); diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index cbdaa77f..c053cbe5 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -229,6 +229,51 @@ > + +
+ + +
Troubleshoot your in-app notifications… @@ -1102,6 +1147,7 @@ import { configureNativeFetcherIfReady, deleteSmsPhone, getNotificationApiBaseUrl, + initializeNativePushAndFirebaseMessaging, listSmsPhones, localTimeTextToUtc, revokeInAppNewActivity, @@ -1111,6 +1157,12 @@ import { UnsupportedIdentityError, } from "@/services/notifications"; import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { serverPushSupported } from "@/services/notifications/firebaseMessagingClient"; +import { + effectiveNewActivityDelivery, + type NewActivityDelivery, +} from "@/services/notifications/newActivityDelivery"; +import { unregisterDevicesFromNotificationApi } from "@/services/notifications/NotificationService"; import type { AlertSearchUploadState } from "@/interfaces/notifyApi"; import { type CursorPair, @@ -1223,6 +1275,12 @@ export default class AccountViewView extends Vue { // Notification properties notifyingNewActivity: boolean = false; notifyingNewActivityTime: string = ""; + /** The in-app delivery that runs; see newActivityDelivery.ts. */ + newActivityDelivery: NewActivityDelivery = "server"; + /** False in a build without Firebase, where only "device" can run. */ + serverPushAvailable = true; + deliveryChangeInProgress = false; + isIos = Capacitor.getPlatform() === "ios"; // SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes. // Settings hold what the service cannot report back: it has a route to list // registered numbers but none to read an authorization, so the chosen time @@ -1421,6 +1479,11 @@ export default class AccountViewView extends Vue { !!settings.searchBoxes && settings.searchBoxes.length > 0; this.notifyingNewActivity = !!settings.notifyingNewActivityTime; this.notifyingNewActivityTime = settings.notifyingNewActivityTime || ""; + this.serverPushAvailable = await serverPushSupported(); + this.newActivityDelivery = effectiveNewActivityDelivery( + settings, + this.serverPushAvailable, + ); this.notifyingNewActivitySmsPhone = settings.notifyingNewActivitySmsPhone || ""; this.notifyingNewActivitySmsTime = @@ -1611,17 +1674,13 @@ export default class AccountViewView extends Vue { }); this.notifyingNewActivity = true; this.notifyingNewActivityTime = timeText; - if (Capacitor.isNativePlatform()) { - await this.scheduleNewActivityDualNotification(timeText); + if (this.newActivityDelivery === "device") { + if (Capacitor.isNativePlatform()) { + await this.scheduleNewActivityDualNotification(timeText); + } + } else { + await this.uploadServerNewActivity(timeText); } - const searchState = await this.newActivityStartState("fcm"); - await authorizeInAppNewActivity( - (message) => { - this.reportAlertSearchAuthorizationFailure("upload", message); - }, - undefined, - searchState, - ); } }); } else { @@ -1644,12 +1703,27 @@ export default class AccountViewView extends Vue { * @returns false when the revoke failed and nothing changed */ private async turnOffInAppNewActivity(): Promise { - const revoked = await revokeInAppNewActivity((message) => { - this.reportAlertSearchAuthorizationFailure("revoke", message); - }); - if (!revoked) { - return false; + if (this.newActivityDelivery === "server") { + const revoked = await revokeInAppNewActivity((message) => { + this.reportAlertSearchAuthorizationFailure("revoke", message); + }); + if (!revoked) { + return false; + } } + // also in server mode, to clear a device schedule set up before the + // delivery choice existed + await this.cancelDeviceNewActivity(); + await this.$saveSettings({ + notifyingNewActivityTime: "", + }); + this.notifyingNewActivity = false; + this.notifyingNewActivityTime = ""; + return true; + } + + /** Cancel the on-device New Activity schedule, if the plugin has one. */ + private async cancelDeviceNewActivity(): Promise { if (Capacitor.isNativePlatform() && DailyNotification?.cancelDualSchedule) { try { await DailyNotification.cancelDualSchedule(); @@ -1657,12 +1731,92 @@ export default class AccountViewView extends Vue { logger.error("[AccountViewView] cancelDualSchedule failed:", e); } } - await this.$saveSettings({ - notifyingNewActivityTime: "", - }); - this.notifyingNewActivity = false; - this.notifyingNewActivityTime = ""; - return true; + } + + /** + * Register this device for pushes and upload a batch counting from now, at + * `timeText`. A deliberate reset, so it may fire today even after today's + * push went. + * + * @returns false when the upload failed (the failure was reported) + */ + private async uploadServerNewActivity(timeText: string): Promise { + await initializeNativePushAndFirebaseMessaging(); + const searchState = await this.newActivityStartState("fcm"); + const result = await authorizeInAppNewActivity( + (message) => { + this.reportAlertSearchAuthorizationFailure("upload", message); + }, + undefined, + searchState, + { timeText, rerunToday: true }, + ); + return result.ok; + } + + /** + * Remove this identity's device registrations from the notification + * service. Best effort: a failure is logged, and the service only pushes to + * an identity that also holds an alert authorization. + */ + private async forgetPushRegistration(): Promise { + try { + await unregisterDevicesFromNotificationApi(); + } catch (error) { + logger.warn( + "[AccountViewView] Could not remove device registrations:", + error, + ); + } + } + + /** + * Switch how in-app New Activity notifications arrive. With notifications + * on, the old delivery is stopped and the new one started at the same + * time; a failure on the way leaves the old delivery in place. + */ + async chooseNewActivityDelivery(choice: NewActivityDelivery): Promise { + if (choice === this.newActivityDelivery || this.deliveryChangeInProgress) { + return; + } + if (choice === "server" && !this.serverPushAvailable) { + this.notify.warning( + "Server notifications are not supported in this build of the app: " + + "it was built without Google's push service (Firebase), as F-Droid " + + "builds are. On-device notifications still work.", + TIMEOUTS.VERY_LONG, + ); + return; + } + this.deliveryChangeInProgress = true; + try { + if (choice === "device") { + if (this.notifyingNewActivity) { + const revoked = await revokeInAppNewActivity((message) => { + this.reportAlertSearchAuthorizationFailure("revoke", message); + }); + if (!revoked) return; + if (Capacitor.isNativePlatform()) { + await this.scheduleNewActivityDualNotification( + this.notifyingNewActivityTime, + ); + } + } + await this.forgetPushRegistration(); + } else if (this.notifyingNewActivity) { + const uploaded = await this.uploadServerNewActivity( + this.notifyingNewActivityTime, + ); + if (!uploaded) return; + await this.cancelDeviceNewActivity(); + } else { + await initializeNativePushAndFirebaseMessaging(); + } + await this.$saveSettings({ newActivityDelivery: choice }); + this.newActivityDelivery = choice; + } finally { + this.deliveryChangeInProgress = false; + } } /** @@ -2389,7 +2543,30 @@ export default class AccountViewView extends Vue { DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => { if (!success) return; - if (Capacitor.isNativePlatform()) { + if (this.newActivityDelivery === "server") { + // The service schedules the push from its batch, so a new time + // means a new batch. It keeps its cursors, so nothing waiting for + // the next push is dropped, and it may fire today: a new time is a + // reset. + const uploaded = await authorizeInAppNewActivity( + (message) => { + this.reportAlertSearchAuthorizationFailure("upload", message); + }, + undefined, + alertSearchUploadState( + buildNewActivityContext( + this.axios, + this.activeDid, + await this.$accountSettings(), + ), + undefined, + ), + { timeText, rerunToday: true }, + ); + if (!uploaded.ok) { + return; + } + } else if (Capacitor.isNativePlatform()) { const time24h = this.parseTimeTo24Hour(timeText); const config = buildDualScheduleConfig({ notifyTime: time24h }); const plugin = DailyNotification as unknown as {