separate the on-device vs server-push notifications

This commit is contained in:
2026-10-25 15:33:06 -06:00
parent 1cfe91aae6
commit 192628f318
17 changed files with 704 additions and 45 deletions
@@ -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.<id>}`; `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.
+1
View File
@@ -1,6 +1,7 @@
## Tasks ## 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()` - [ ] Default `apiServer` in every settings fallback. `$accountSettings()`
(`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often (`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
`{}`) on its early-return and error paths, so `apiServer` can be undefined; `{}`) on its early-return and error paths, so `apiServer` can be undefined;
+8
View File
@@ -306,6 +306,14 @@ const MIGRATIONS = [
ALTER TABLE settings ADD COLUMN newActivityCursors TEXT; 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;
`,
},
]; ];
/** /**
+2
View File
@@ -47,6 +47,8 @@ export type Settings = {
// JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen // JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen
newActivityCursors?: string; 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 // The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string; lastNotifiedClaimId?: string;
+1
View File
@@ -30,6 +30,7 @@ export interface AccountSettings {
name: string; name: string;
bbox: BoundingBox; bbox: BoundingBox;
}>; }>;
newActivityDelivery?: string;
notifyingNewActivityTime?: string; notifyingNewActivityTime?: string;
notifyingNewActivitySmsPhone?: string; notifyingNewActivitySmsPhone?: string;
notifyingNewActivitySmsTime?: string; notifyingNewActivitySmsTime?: string;
+2 -2
View File
@@ -195,8 +195,8 @@ export interface AlertAuthorizationRequestBody {
/** /**
* True stores the batch's JWT for a UTC day that already ran, so a reset can * 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 * 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 * the automatic re-upload after a New Activity visit. Each route honors it
* it. * only while today's alert can still go out.
*/ */
rerunToday?: boolean; rerunToday?: boolean;
} }
+28 -2
View File
@@ -50,6 +50,13 @@ import {
onNotificationAuthMayBeReady, onNotificationAuthMayBeReady,
runLegacyApiNotificationsCleanupOnce, runLegacyApiNotificationsCleanupOnce,
} from "@/services/notifications"; } 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] 🚀 Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
@@ -364,8 +371,27 @@ setTimeout(async () => {
logger.info(`[Main] 🎉 Deep link system fully initialized!`); logger.info(`[Main] 🎉 Deep link system fully initialized!`);
// One-time: remove persisted legacy api_* schedules from pre-retirement installs // One-time: remove persisted legacy api_* schedules from pre-retirement installs
await runLegacyApiNotificationsCleanupOnce(); await runLegacyApiNotificationsCleanupOnce();
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners) // Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token,
await initializeNativePushAndFirebaseMessaging(); // 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) // Configure native fetcher for API-driven daily notifications (activeDid + JWT)
await configureNativeFetcherIfReady(); await configureNativeFetcherIfReady();
} catch (error) { } catch (error) {
@@ -80,6 +80,30 @@ export async function registerToken(fcmToken: string): Promise<void> {
} }
} }
/**
* 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<void> {
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 * Options for scheduling a daily notification
*/ */
@@ -16,6 +16,8 @@ jest.mock("@/libs/crypto/vc", () => ({
jest.mock("./alertAuthorizationBatch", () => ({ jest.mock("./alertAuthorizationBatch", () => ({
ALERT_AUTHORIZATION_BATCH_DAYS: 100, ALERT_AUTHORIZATION_BATCH_DAYS: 100,
mintAlertAuthorizationBatch: jest.fn(), mintAlertAuthorizationBatch: jest.fn(),
localTimeTextToUtc: (text: string) =>
text === "8:30 PM" ? { notifyHourUtc: 2, notifyMinuteUtc: 30 } : null,
})); }));
jest.mock("./NotificationDebugConfig", () => ({ jest.mock("./NotificationDebugConfig", () => ({
getNotificationApiBaseUrl: jest.fn(), 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 () => { it("does not log delegated JWT contents", async () => {
const logSpy = jest.spyOn(console, "log").mockImplementation(); const logSpy = jest.spyOn(console, "log").mockImplementation();
const warnSpy = jest.spyOn(console, "warn").mockImplementation(); const warnSpy = jest.spyOn(console, "warn").mockImplementation();
@@ -2,10 +2,12 @@
* Push-channel AlertSearch authorization against notification-wakeup-service. * Push-channel AlertSearch authorization against notification-wakeup-service.
* *
* Upload mints a delegated JWT batch for the active identity and * Upload mints a delegated JWT batch for the active identity and
* `POST /notifications/alert-authorization`, with the send time fixed at * `POST /notifications/alert-authorization`, with the send time the person
* 09:00 in the device's zone. Revoke sends `DELETE` on that same route and * picked for in-app New Activity notifications, converted to UTC. Without a
* does not unregister the FCM device. The debug panel uploads manually; the * time (the debug panel) it sends 09:00 in the device's zone. Revoke sends
* in-app New Activity toggle uploads on enable and revokes on disable. * `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 * The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in * both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
@@ -20,6 +22,7 @@ import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import { import {
ALERT_AUTHORIZATION_BATCH_DAYS, ALERT_AUTHORIZATION_BATCH_DAYS,
type AlertAuthorizationBatch, type AlertAuthorizationBatch,
localTimeTextToUtc,
mintAlertAuthorizationBatch, mintAlertAuthorizationBatch,
} from "./alertAuthorizationBatch"; } from "./alertAuthorizationBatch";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime"; import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
@@ -70,7 +73,7 @@ export interface AlertAuthorizationDependencies {
getActiveDid: () => Promise<string | null>; getActiveDid: () => Promise<string | null>;
isAuthBypassEnabled: () => boolean; isAuthBypassEnabled: () => boolean;
mintBatch: (did: string) => Promise<AlertAuthorizationBatch>; mintBatch: (did: string) => Promise<AlertAuthorizationBatch>;
/** 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; getTimeZone: () => string;
getAuthHeaders: typeof getNotificationApiHeaders; getAuthHeaders: typeof getNotificationApiHeaders;
getBaseUrl: () => string; getBaseUrl: () => string;
@@ -87,10 +90,25 @@ const defaultDependencies: AlertAuthorizationDependencies = {
fetch: (input, init) => globalThis.fetch(input, init), 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( function buildRequestBody(
batch: AlertAuthorizationBatch, batch: AlertAuthorizationBatch,
timeZone: string, timeZone: string,
searchState: AlertSearchUploadState = {}, searchState: AlertSearchUploadState = {},
options: AlertAuthorizationUploadOptions = {},
): AlertAuthorizationRequestBody & AlertSearchUploadState { ): AlertAuthorizationRequestBody & AlertSearchUploadState {
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) { if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
throw new Error( 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 { return {
batchId: batch.batchId, batchId: batch.batchId,
@@ -107,6 +131,7 @@ function buildRequestBody(
timezone: timeZone, timezone: timeZone,
jwts: batch.jwts, jwts: batch.jwts,
...searchState, ...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 * @param searchState cursors to count from, and the starred handle IDs and
* area the daily search uses; see {@link AlertSearchUploadState} * area the daily search uses; see {@link AlertSearchUploadState}
* @param options the send time and whether this is a reset
*/ */
export async function uploadAlertSearchAuthorization( export async function uploadAlertSearchAuthorization(
dependencies: AlertAuthorizationDependencies = defaultDependencies, dependencies: AlertAuthorizationDependencies = defaultDependencies,
searchState?: AlertSearchUploadState, searchState?: AlertSearchUploadState,
options?: AlertAuthorizationUploadOptions,
): Promise<AlertAuthorizationUploadResult> { ): Promise<AlertAuthorizationUploadResult> {
try { try {
const access = await requireDelegatedAlertAccess(dependencies, "uploaded"); const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
@@ -224,7 +251,7 @@ export async function uploadAlertSearchAuthorization(
const batch = await dependencies.mintBatch(access.did); const batch = await dependencies.mintBatch(access.did);
const timeZone = dependencies.getTimeZone(); const timeZone = dependencies.getTimeZone();
const body = buildRequestBody(batch, timeZone, searchState); const body = buildRequestBody(batch, timeZone, searchState, options);
const response = await dependencies.fetch( const response = await dependencies.fetch(
`${dependencies.getBaseUrl()}/notifications/alert-authorization`, `${dependencies.getBaseUrl()}/notifications/alert-authorization`,
@@ -74,7 +74,7 @@ async function registerRetrievedToken(
* config, and register() there throws natively, crashing the app before any * config, and register() there throws natively, crashing the app before any
* JS catch can run. * JS catch can run.
*/ */
async function canRegisterNativePush(): Promise<boolean> { export async function canRegisterNativePush(): Promise<boolean> {
if (Capacitor.getPlatform() !== "android") { if (Capacitor.getPlatform() !== "android") {
return true; return true;
} }
@@ -87,6 +87,15 @@ async function canRegisterNativePush(): Promise<boolean> {
} }
} }
/**
* 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<boolean> {
return Capacitor.isNativePlatform() && (await canRegisterNativePush());
}
/** Most recent FCM token from native/web push registration (for debug UI). */ /** Most recent FCM token from native/web push registration (for debug UI). */
export function getLastKnownFcmToken(): string | null { export function getLastKnownFcmToken(): string | null {
return lastSeenFcmToken ?? lastRegisteredFcmToken; return lastSeenFcmToken ?? lastRegisteredFcmToken;
@@ -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";
}
@@ -12,23 +12,26 @@ import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
import { import {
type AlertAuthorizationDependencies, type AlertAuthorizationDependencies,
type AlertAuthorizationRevokeResult, type AlertAuthorizationRevokeResult,
type AlertAuthorizationUploadOptions,
type AlertAuthorizationUploadResult, type AlertAuthorizationUploadResult,
revokeAlertSearchAuthorization, revokeAlertSearchAuthorization,
uploadAlertSearchAuthorization, uploadAlertSearchAuthorization,
} from "./alertAuthorization"; } from "./alertAuthorization";
/** /**
* Upload the AlertSearch batch after the in-app setting has been enabled. * Upload the AlertSearch batch when the in-app setting is enabled or given a
* A failure is reported and returned; it does not throw. * new time. A failure is reported and returned; it does not throw.
*/ */
export async function authorizeInAppNewActivity( export async function authorizeInAppNewActivity(
reportError: (message: string) => void, reportError: (message: string) => void,
dependencies?: AlertAuthorizationDependencies, dependencies?: AlertAuthorizationDependencies,
searchState?: AlertSearchUploadState, searchState?: AlertSearchUploadState,
options?: AlertAuthorizationUploadOptions,
): Promise<AlertAuthorizationUploadResult> { ): Promise<AlertAuthorizationUploadResult> {
const result = await uploadAlertSearchAuthorization( const result = await uploadAlertSearchAuthorization(
dependencies, dependencies,
searchState, searchState,
options,
); );
if (!result.ok) { if (!result.ok) {
reportError(result.errorMessage); reportError(result.errorMessage);
@@ -14,9 +14,11 @@ import { logger } from "@/utils/logger";
import { localTimeTextToUtc } from "./alertAuthorizationBatch"; import { localTimeTextToUtc } from "./alertAuthorizationBatch";
import { uploadAlertSearchAuthorization } from "./alertAuthorization"; import { uploadAlertSearchAuthorization } from "./alertAuthorization";
import { authorizeSmsAlertSearch } from "./smsNotificationApi"; import { authorizeSmsAlertSearch } from "./smsNotificationApi";
import { storedNewActivityDelivery } from "./newActivityDelivery";
/** The settings that say which channels are on. */ /** The settings that say which channels are on. */
export interface NotificationChannelSettings { export interface NotificationChannelSettings {
newActivityDelivery?: string;
notifyingNewActivityTime?: string; notifyingNewActivityTime?: string;
notifyingNewActivitySmsTime?: string; notifyingNewActivitySmsTime?: string;
smsNotificationsEnabled?: boolean; smsNotificationsEnabled?: boolean;
@@ -26,7 +28,13 @@ export function enabledNotificationChannels(
settings: NotificationChannelSettings, settings: NotificationChannelSettings,
): NotificationChannel[] { ): NotificationChannel[] {
const channels: 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 ( if (
settings.smsNotificationsEnabled && settings.smsNotificationsEnabled &&
settings.notifyingNewActivitySmsTime settings.notifyingNewActivitySmsTime
@@ -37,7 +45,10 @@ export function enabledNotificationChannels(
} }
export interface NotificationRefreshDependencies { export interface NotificationRefreshDependencies {
uploadFcm: (state: AlertSearchUploadState) => Promise<boolean>; uploadFcm: (
state: AlertSearchUploadState,
timeText: string,
) => Promise<boolean>;
uploadSms: ( uploadSms: (
did: string, did: string,
notifyHourUtc: number, notifyHourUtc: number,
@@ -47,8 +58,10 @@ export interface NotificationRefreshDependencies {
} }
const defaultDependencies: NotificationRefreshDependencies = { const defaultDependencies: NotificationRefreshDependencies = {
uploadFcm: async (state) => { uploadFcm: async (state, timeText) => {
const result = await uploadAlertSearchAuthorization(undefined, state); const result = await uploadAlertSearchAuthorization(undefined, state, {
timeText,
});
if (!result.ok) { if (!result.ok) {
logger.warn( logger.warn(
"[NewActivityRefresh] Push re-authorization failed:", "[NewActivityRefresh] Push re-authorization failed:",
@@ -82,7 +95,15 @@ export async function refreshNotificationAuthorizations(
try { try {
let ok = false; let ok = false;
if (channel === "fcm") { 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 { } else {
const utc = localTimeTextToUtc( const utc = localTimeTextToUtc(
settings.notifyingNewActivitySmsTime || "", settings.notifyingNewActivitySmsTime || "",
+36 -4
View File
@@ -628,6 +628,15 @@ describe("notification refresh", () => {
).toEqual(["sms"]); ).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 () => { it("uploads each channel with the cursors and reports the ones that took", async () => {
const state = alertSearchUploadState( const state = alertSearchUploadState(
{ planHandleIds: ["h1"], location: undefined }, { planHandleIds: ["h1"], location: undefined },
@@ -643,21 +652,44 @@ describe("notification refresh", () => {
const uploadSms = jest.fn().mockResolvedValue(true); const uploadSms = jest.fn().mockResolvedValue(true);
const uploaded = await refreshNotificationAuthorizations( const uploaded = await refreshNotificationAuthorizations(
"did:ethr:0xme", "did:ethr:0xme",
{ notifyingNewActivitySmsTime: "9:00 AM" }, {
notifyingNewActivityTime: "9:00 AM",
notifyingNewActivitySmsTime: "9:00 AM",
},
["fcm", "sms"], ["fcm", "sms"],
cursors, cursors,
state, state,
{ uploadFcm, uploadSms }, { 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(uploadSms).toHaveBeenCalledWith("did:ethr:0xme", 15, 0, state);
expect(uploaded).toEqual({ sms: cursors }); 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( const uploaded = await refreshNotificationAuthorizations(
"did:ethr:0xme", "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"], ["fcm", "sms"],
cursors, cursors,
{}, {},
+24
View File
@@ -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");
});
});
+199 -22
View File
@@ -229,6 +229,51 @@
></div> ></div>
</div> </div>
</div> </div>
<!-- How in-app notifications arrive; exactly one of the two runs -->
<div
v-if="showGeneralAdvanced"
class="mb-2 pl-8 text-sm"
role="radiogroup"
aria-label="How in-app notifications arrive"
data-testId="newActivityDelivery"
>
<label
class="flex items-start gap-2 mb-1"
:class="serverPushAvailable ? '' : 'text-slate-400'"
>
<input
type="radio"
name="newActivityDelivery"
class="mt-1"
:checked="newActivityDelivery === 'server'"
:aria-disabled="!serverPushAvailable"
@click.prevent="chooseNewActivityDelivery('server')"
/>
<span>
From the notification server (default). It checks for you, so it
keeps permission to search on your behalf.
</span>
</label>
<label class="flex items-start gap-2">
<input
type="radio"
name="newActivityDelivery"
class="mt-1"
:checked="newActivityDelivery === 'device'"
@click.prevent="chooseNewActivityDelivery('device')"
/>
<span>
Only on this device (more private). Your phone checks, and the
notification server keeps nothing. For now it checks only your
starred projects.
<span v-if="isIos" class="block text-slate-500">
On iPhone this is less reliable: iOS decides when apps refresh
in the background, so a notification can come late or not at
all. To learn more, search for "iOS Background App Refresh".
</span>
</span>
</label>
</div>
<div v-if="showGeneralAdvanced" class="mb-2 pl-1"> <div v-if="showGeneralAdvanced" class="mb-2 pl-1">
<router-link class="text-sm text-blue-500" to="/help-notifications"> <router-link class="text-sm text-blue-500" to="/help-notifications">
Troubleshoot your in-app notifications&hellip; Troubleshoot your in-app notifications&hellip;
@@ -1102,6 +1147,7 @@ import {
configureNativeFetcherIfReady, configureNativeFetcherIfReady,
deleteSmsPhone, deleteSmsPhone,
getNotificationApiBaseUrl, getNotificationApiBaseUrl,
initializeNativePushAndFirebaseMessaging,
listSmsPhones, listSmsPhones,
localTimeTextToUtc, localTimeTextToUtc,
revokeInAppNewActivity, revokeInAppNewActivity,
@@ -1111,6 +1157,12 @@ import {
UnsupportedIdentityError, UnsupportedIdentityError,
} from "@/services/notifications"; } from "@/services/notifications";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; 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 { AlertSearchUploadState } from "@/interfaces/notifyApi";
import { import {
type CursorPair, type CursorPair,
@@ -1223,6 +1275,12 @@ export default class AccountViewView extends Vue {
// Notification properties // Notification properties
notifyingNewActivity: boolean = false; notifyingNewActivity: boolean = false;
notifyingNewActivityTime: string = ""; 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. // 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 // 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 // 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; !!settings.searchBoxes && settings.searchBoxes.length > 0;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime; this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || ""; this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.serverPushAvailable = await serverPushSupported();
this.newActivityDelivery = effectiveNewActivityDelivery(
settings,
this.serverPushAvailable,
);
this.notifyingNewActivitySmsPhone = this.notifyingNewActivitySmsPhone =
settings.notifyingNewActivitySmsPhone || ""; settings.notifyingNewActivitySmsPhone || "";
this.notifyingNewActivitySmsTime = this.notifyingNewActivitySmsTime =
@@ -1611,17 +1674,13 @@ export default class AccountViewView extends Vue {
}); });
this.notifyingNewActivity = true; this.notifyingNewActivity = true;
this.notifyingNewActivityTime = timeText; this.notifyingNewActivityTime = timeText;
if (Capacitor.isNativePlatform()) { if (this.newActivityDelivery === "device") {
await this.scheduleNewActivityDualNotification(timeText); 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 { } else {
@@ -1644,12 +1703,27 @@ export default class AccountViewView extends Vue {
* @returns false when the revoke failed and nothing changed * @returns false when the revoke failed and nothing changed
*/ */
private async turnOffInAppNewActivity(): Promise<boolean> { private async turnOffInAppNewActivity(): Promise<boolean> {
const revoked = await revokeInAppNewActivity((message) => { if (this.newActivityDelivery === "server") {
this.reportAlertSearchAuthorizationFailure("revoke", message); const revoked = await revokeInAppNewActivity((message) => {
}); this.reportAlertSearchAuthorizationFailure("revoke", message);
if (!revoked) { });
return false; 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<void> {
if (Capacitor.isNativePlatform() && DailyNotification?.cancelDualSchedule) { if (Capacitor.isNativePlatform() && DailyNotification?.cancelDualSchedule) {
try { try {
await DailyNotification.cancelDualSchedule(); await DailyNotification.cancelDualSchedule();
@@ -1657,12 +1731,92 @@ export default class AccountViewView extends Vue {
logger.error("[AccountViewView] cancelDualSchedule failed:", e); logger.error("[AccountViewView] cancelDualSchedule failed:", e);
} }
} }
await this.$saveSettings({ }
notifyingNewActivityTime: "",
}); /**
this.notifyingNewActivity = false; * Register this device for pushes and upload a batch counting from now, at
this.notifyingNewActivityTime = ""; * `timeText`. A deliberate reset, so it may fire today even after today's
return true; * push went.
*
* @returns false when the upload failed (the failure was reported)
*/
private async uploadServerNewActivity(timeText: string): Promise<boolean> {
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<void> {
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<void> {
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, DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => { async (success: boolean, timeText: string) => {
if (!success) return; 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 time24h = this.parseTimeTo24Hour(timeText);
const config = buildDualScheduleConfig({ notifyTime: time24h }); const config = buildDualScheduleConfig({ notifyTime: time24h });
const plugin = DailyNotification as unknown as { const plugin = DailyNotification as unknown as {