separate the on-device vs server-push notifications
This commit is contained in:
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 || "",
|
||||||
|
|||||||
@@ -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,
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -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
@@ -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…
|
Troubleshoot your in-app notifications…
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user