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
|
||||
|
||||
- [ ] On-device New Activity notifications through alertSearch — next: plugin request-job runner (`configureRequestJob`), then the app's job builder; see [PLAN-device-alertsearch-notifications.md](PLAN-device-alertsearch-notifications.md) (Trent)
|
||||
- [ ] Default `apiServer` in every settings fallback. `$accountSettings()`
|
||||
(`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
|
||||
`{}`) on its early-return and error paths, so `apiServer` can be undefined;
|
||||
|
||||
@@ -306,6 +306,14 @@ const MIGRATIONS = [
|
||||
ALTER TABLE settings ADD COLUMN newActivityCursors TEXT;
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "013_add_newActivityDelivery_to_settings",
|
||||
sql: `
|
||||
-- How in-app New Activity notifications arrive: 'server' (push from the
|
||||
-- notification service, the default when blank) or 'device'
|
||||
ALTER TABLE settings ADD COLUMN newActivityDelivery TEXT;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,8 @@ export type Settings = {
|
||||
|
||||
// JSON text of NewActivityCursorState (src/libs/newActivity.ts): the alertSearch cursors for the New Activity screen
|
||||
newActivityCursors?: string;
|
||||
// how in-app New Activity notifications arrive: "server" (push; the default when unset) or "device" (on-device schedule)
|
||||
newActivityDelivery?: string;
|
||||
|
||||
// The claim list has a most recent one used in notifications that's separate from the last viewed
|
||||
lastNotifiedClaimId?: string;
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface AccountSettings {
|
||||
name: string;
|
||||
bbox: BoundingBox;
|
||||
}>;
|
||||
newActivityDelivery?: string;
|
||||
notifyingNewActivityTime?: string;
|
||||
notifyingNewActivitySmsPhone?: 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
|
||||
* fire again today; absent skips such days. Sent by deliberate resets, not by
|
||||
* the automatic re-upload after a New Activity visit. The push route ignores
|
||||
* it.
|
||||
* the automatic re-upload after a New Activity visit. Each route honors it
|
||||
* only while today's alert can still go out.
|
||||
*/
|
||||
rerunToday?: boolean;
|
||||
}
|
||||
|
||||
+28
-2
@@ -50,6 +50,13 @@ import {
|
||||
onNotificationAuthMayBeReady,
|
||||
runLegacyApiNotificationsCleanupOnce,
|
||||
} from "@/services/notifications";
|
||||
import {
|
||||
effectiveNewActivityDelivery,
|
||||
storedNewActivityDelivery,
|
||||
} from "@/services/notifications/newActivityDelivery";
|
||||
import { serverPushSupported } from "@/services/notifications/firebaseMessagingClient";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
import { retrieveSettingsForActiveAccount } from "@/db/databaseUtil";
|
||||
|
||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||||
@@ -364,8 +371,27 @@ setTimeout(async () => {
|
||||
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
|
||||
// One-time: remove persisted legacy api_* schedules from pre-retirement installs
|
||||
await runLegacyApiNotificationsCleanupOnce();
|
||||
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners)
|
||||
await initializeNativePushAndFirebaseMessaging();
|
||||
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token,
|
||||
// delivery listeners). Skipped for on-device New Activity delivery, so the
|
||||
// device is not registered with the notification service.
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
if (storedNewActivityDelivery(settings) === "server") {
|
||||
await initializeNativePushAndFirebaseMessaging();
|
||||
}
|
||||
// Exactly one New Activity notifier runs. Installs from before the
|
||||
// delivery choice also have the on-device schedule; drop it where the
|
||||
// server push is the one that runs.
|
||||
if (
|
||||
settings.notifyingNewActivityTime &&
|
||||
effectiveNewActivityDelivery(settings, await serverPushSupported()) ===
|
||||
"server"
|
||||
) {
|
||||
try {
|
||||
await DailyNotification.cancelDualSchedule?.();
|
||||
} catch (error) {
|
||||
logger.warn("[Main] cancelDualSchedule failed:", error);
|
||||
}
|
||||
}
|
||||
// Configure native fetcher for API-driven daily notifications (activeDid + JWT)
|
||||
await configureNativeFetcherIfReady();
|
||||
} catch (error) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,8 @@ jest.mock("@/libs/crypto/vc", () => ({
|
||||
jest.mock("./alertAuthorizationBatch", () => ({
|
||||
ALERT_AUTHORIZATION_BATCH_DAYS: 100,
|
||||
mintAlertAuthorizationBatch: jest.fn(),
|
||||
localTimeTextToUtc: (text: string) =>
|
||||
text === "8:30 PM" ? { notifyHourUtc: 2, notifyMinuteUtc: 30 } : null,
|
||||
}));
|
||||
jest.mock("./NotificationDebugConfig", () => ({
|
||||
getNotificationApiBaseUrl: jest.fn(),
|
||||
@@ -145,6 +147,43 @@ describe("uploadAlertSearchAuthorization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the picked time, and rerunToday only for a reset", async () => {
|
||||
const { dependencies, fetchMock } = createDependencies();
|
||||
await uploadAlertSearchAuthorization(
|
||||
dependencies,
|
||||
{ endorserAfterCursor: "100" },
|
||||
{ timeText: "8:30 PM", rerunToday: true },
|
||||
);
|
||||
const reset = JSON.parse(String(fetchMock.mock.calls[0][1].body));
|
||||
expect(reset.notifyHourUtc).toBe(2);
|
||||
expect(reset.notifyMinuteUtc).toBe(30);
|
||||
expect(reset.rerunToday).toBe(true);
|
||||
expect(reset.endorserAfterCursor).toBe("100");
|
||||
|
||||
await uploadAlertSearchAuthorization(dependencies, undefined, {
|
||||
timeText: "8:30 PM",
|
||||
});
|
||||
const refresh = JSON.parse(String(fetchMock.mock.calls[1][1].body));
|
||||
expect(refresh.notifyHourUtc).toBe(2);
|
||||
expect(refresh).not.toHaveProperty("rerunToday");
|
||||
});
|
||||
|
||||
it("does not submit an unreadable time", async () => {
|
||||
const { dependencies, fetchMock } = createDependencies();
|
||||
const result = await uploadAlertSearchAuthorization(
|
||||
dependencies,
|
||||
undefined,
|
||||
{
|
||||
timeText: "whenever",
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errorMessage: "Unreadable notification time: whenever",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not log delegated JWT contents", async () => {
|
||||
const logSpy = jest.spyOn(console, "log").mockImplementation();
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* Push-channel AlertSearch authorization against notification-wakeup-service.
|
||||
*
|
||||
* Upload mints a delegated JWT batch for the active identity and
|
||||
* `POST /notifications/alert-authorization`, with the send time fixed at
|
||||
* 09:00 in the device's zone. Revoke sends `DELETE` on that same route and
|
||||
* does not unregister the FCM device. The debug panel uploads manually; the
|
||||
* in-app New Activity toggle uploads on enable and revokes on disable.
|
||||
* `POST /notifications/alert-authorization`, with the send time the person
|
||||
* picked for in-app New Activity notifications, converted to UTC. Without a
|
||||
* time (the debug panel) it sends 09:00 in the device's zone. Revoke sends
|
||||
* `DELETE` on that same route and does not unregister the FCM device. The
|
||||
* in-app New Activity toggle uploads on enable and on a new time, and revokes
|
||||
* on disable.
|
||||
*
|
||||
* The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and
|
||||
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
|
||||
@@ -20,6 +22,7 @@ import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
|
||||
import {
|
||||
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||
type AlertAuthorizationBatch,
|
||||
localTimeTextToUtc,
|
||||
mintAlertAuthorizationBatch,
|
||||
} from "./alertAuthorizationBatch";
|
||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||
@@ -70,7 +73,7 @@ export interface AlertAuthorizationDependencies {
|
||||
getActiveDid: () => Promise<string | null>;
|
||||
isAuthBypassEnabled: () => boolean;
|
||||
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;
|
||||
getAuthHeaders: typeof getNotificationApiHeaders;
|
||||
getBaseUrl: () => string;
|
||||
@@ -87,10 +90,25 @@ const defaultDependencies: AlertAuthorizationDependencies = {
|
||||
fetch: (input, init) => globalThis.fetch(input, init),
|
||||
};
|
||||
|
||||
/** How an upload schedules the batch. */
|
||||
export interface AlertAuthorizationUploadOptions {
|
||||
/**
|
||||
* 12-hour local reading from the time picker, such as "8:30 PM". Absent:
|
||||
* 09:00 in the device's zone.
|
||||
*/
|
||||
timeText?: string;
|
||||
/**
|
||||
* A deliberate reset (turning the channel on, a new time): today's run may
|
||||
* go again even after it ran. Left out of the re-upload after a visit.
|
||||
*/
|
||||
rerunToday?: boolean;
|
||||
}
|
||||
|
||||
function buildRequestBody(
|
||||
batch: AlertAuthorizationBatch,
|
||||
timeZone: string,
|
||||
searchState: AlertSearchUploadState = {},
|
||||
options: AlertAuthorizationUploadOptions = {},
|
||||
): AlertAuthorizationRequestBody & AlertSearchUploadState {
|
||||
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
|
||||
throw new Error(
|
||||
@@ -98,7 +116,13 @@ function buildRequestBody(
|
||||
);
|
||||
}
|
||||
|
||||
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(timeZone);
|
||||
const notifyTime =
|
||||
options.timeText === undefined
|
||||
? alertSearchNotifyTimeUtcFromLocalNineAm(timeZone)
|
||||
: localTimeTextToUtc(options.timeText);
|
||||
if (!notifyTime) {
|
||||
throw new Error("Unreadable notification time: " + options.timeText);
|
||||
}
|
||||
|
||||
return {
|
||||
batchId: batch.batchId,
|
||||
@@ -107,6 +131,7 @@ function buildRequestBody(
|
||||
timezone: timeZone,
|
||||
jwts: batch.jwts,
|
||||
...searchState,
|
||||
...(options.rerunToday ? { rerunToday: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,10 +236,12 @@ async function authorizationHttpFailure(response: Response): Promise<{
|
||||
*
|
||||
* @param searchState cursors to count from, and the starred handle IDs and
|
||||
* area the daily search uses; see {@link AlertSearchUploadState}
|
||||
* @param options the send time and whether this is a reset
|
||||
*/
|
||||
export async function uploadAlertSearchAuthorization(
|
||||
dependencies: AlertAuthorizationDependencies = defaultDependencies,
|
||||
searchState?: AlertSearchUploadState,
|
||||
options?: AlertAuthorizationUploadOptions,
|
||||
): Promise<AlertAuthorizationUploadResult> {
|
||||
try {
|
||||
const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
|
||||
@@ -224,7 +251,7 @@ export async function uploadAlertSearchAuthorization(
|
||||
|
||||
const batch = await dependencies.mintBatch(access.did);
|
||||
const timeZone = dependencies.getTimeZone();
|
||||
const body = buildRequestBody(batch, timeZone, searchState);
|
||||
const body = buildRequestBody(batch, timeZone, searchState, options);
|
||||
|
||||
const response = await dependencies.fetch(
|
||||
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
|
||||
|
||||
@@ -74,7 +74,7 @@ async function registerRetrievedToken(
|
||||
* config, and register() there throws natively, crashing the app before any
|
||||
* JS catch can run.
|
||||
*/
|
||||
async function canRegisterNativePush(): Promise<boolean> {
|
||||
export async function canRegisterNativePush(): Promise<boolean> {
|
||||
if (Capacitor.getPlatform() !== "android") {
|
||||
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). */
|
||||
export function getLastKnownFcmToken(): string | null {
|
||||
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 {
|
||||
type AlertAuthorizationDependencies,
|
||||
type AlertAuthorizationRevokeResult,
|
||||
type AlertAuthorizationUploadOptions,
|
||||
type AlertAuthorizationUploadResult,
|
||||
revokeAlertSearchAuthorization,
|
||||
uploadAlertSearchAuthorization,
|
||||
} from "./alertAuthorization";
|
||||
|
||||
/**
|
||||
* Upload the AlertSearch batch after the in-app setting has been enabled.
|
||||
* A failure is reported and returned; it does not throw.
|
||||
* Upload the AlertSearch batch when the in-app setting is enabled or given a
|
||||
* new time. A failure is reported and returned; it does not throw.
|
||||
*/
|
||||
export async function authorizeInAppNewActivity(
|
||||
reportError: (message: string) => void,
|
||||
dependencies?: AlertAuthorizationDependencies,
|
||||
searchState?: AlertSearchUploadState,
|
||||
options?: AlertAuthorizationUploadOptions,
|
||||
): Promise<AlertAuthorizationUploadResult> {
|
||||
const result = await uploadAlertSearchAuthorization(
|
||||
dependencies,
|
||||
searchState,
|
||||
options,
|
||||
);
|
||||
if (!result.ok) {
|
||||
reportError(result.errorMessage);
|
||||
|
||||
@@ -14,9 +14,11 @@ import { logger } from "@/utils/logger";
|
||||
import { localTimeTextToUtc } from "./alertAuthorizationBatch";
|
||||
import { uploadAlertSearchAuthorization } from "./alertAuthorization";
|
||||
import { authorizeSmsAlertSearch } from "./smsNotificationApi";
|
||||
import { storedNewActivityDelivery } from "./newActivityDelivery";
|
||||
|
||||
/** The settings that say which channels are on. */
|
||||
export interface NotificationChannelSettings {
|
||||
newActivityDelivery?: string;
|
||||
notifyingNewActivityTime?: string;
|
||||
notifyingNewActivitySmsTime?: string;
|
||||
smsNotificationsEnabled?: boolean;
|
||||
@@ -26,7 +28,13 @@ export function enabledNotificationChannels(
|
||||
settings: NotificationChannelSettings,
|
||||
): NotificationChannel[] {
|
||||
const channels: NotificationChannel[] = [];
|
||||
if (settings.notifyingNewActivityTime) channels.push("fcm");
|
||||
// on-device delivery keeps nothing on the service to refresh
|
||||
if (
|
||||
settings.notifyingNewActivityTime &&
|
||||
storedNewActivityDelivery(settings) === "server"
|
||||
) {
|
||||
channels.push("fcm");
|
||||
}
|
||||
if (
|
||||
settings.smsNotificationsEnabled &&
|
||||
settings.notifyingNewActivitySmsTime
|
||||
@@ -37,7 +45,10 @@ export function enabledNotificationChannels(
|
||||
}
|
||||
|
||||
export interface NotificationRefreshDependencies {
|
||||
uploadFcm: (state: AlertSearchUploadState) => Promise<boolean>;
|
||||
uploadFcm: (
|
||||
state: AlertSearchUploadState,
|
||||
timeText: string,
|
||||
) => Promise<boolean>;
|
||||
uploadSms: (
|
||||
did: string,
|
||||
notifyHourUtc: number,
|
||||
@@ -47,8 +58,10 @@ export interface NotificationRefreshDependencies {
|
||||
}
|
||||
|
||||
const defaultDependencies: NotificationRefreshDependencies = {
|
||||
uploadFcm: async (state) => {
|
||||
const result = await uploadAlertSearchAuthorization(undefined, state);
|
||||
uploadFcm: async (state, timeText) => {
|
||||
const result = await uploadAlertSearchAuthorization(undefined, state, {
|
||||
timeText,
|
||||
});
|
||||
if (!result.ok) {
|
||||
logger.warn(
|
||||
"[NewActivityRefresh] Push re-authorization failed:",
|
||||
@@ -82,7 +95,15 @@ export async function refreshNotificationAuthorizations(
|
||||
try {
|
||||
let ok = false;
|
||||
if (channel === "fcm") {
|
||||
ok = await dependencies.uploadFcm(state);
|
||||
const timeText = settings.notifyingNewActivityTime || "";
|
||||
if (!localTimeTextToUtc(timeText)) {
|
||||
logger.warn(
|
||||
"[NewActivityRefresh] Unreadable push time; push not re-authorized:",
|
||||
settings.notifyingNewActivityTime,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
ok = await dependencies.uploadFcm(state, timeText);
|
||||
} else {
|
||||
const utc = localTimeTextToUtc(
|
||||
settings.notifyingNewActivitySmsTime || "",
|
||||
|
||||
@@ -628,6 +628,15 @@ describe("notification refresh", () => {
|
||||
).toEqual(["sms"]);
|
||||
});
|
||||
|
||||
it("leaves out push refreshes for on-device delivery", () => {
|
||||
expect(
|
||||
enabledNotificationChannels({
|
||||
notifyingNewActivityTime: "9:00 AM",
|
||||
newActivityDelivery: "device",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("uploads each channel with the cursors and reports the ones that took", async () => {
|
||||
const state = alertSearchUploadState(
|
||||
{ planHandleIds: ["h1"], location: undefined },
|
||||
@@ -643,21 +652,44 @@ describe("notification refresh", () => {
|
||||
const uploadSms = jest.fn().mockResolvedValue(true);
|
||||
const uploaded = await refreshNotificationAuthorizations(
|
||||
"did:ethr:0xme",
|
||||
{ notifyingNewActivitySmsTime: "9:00 AM" },
|
||||
{
|
||||
notifyingNewActivityTime: "9:00 AM",
|
||||
notifyingNewActivitySmsTime: "9:00 AM",
|
||||
},
|
||||
["fcm", "sms"],
|
||||
cursors,
|
||||
state,
|
||||
{ uploadFcm, uploadSms },
|
||||
);
|
||||
expect(uploadFcm).toHaveBeenCalledWith(state);
|
||||
// the push re-upload keeps the person's own time
|
||||
expect(uploadFcm).toHaveBeenCalledWith(state, "9:00 AM");
|
||||
expect(uploadSms).toHaveBeenCalledWith("did:ethr:0xme", 15, 0, state);
|
||||
expect(uploaded).toEqual({ sms: cursors });
|
||||
});
|
||||
|
||||
it("skips texts with an unreadable time and survives a thrown upload", async () => {
|
||||
it("skips a channel with an unreadable time", async () => {
|
||||
const uploadFcm = jest.fn();
|
||||
const uploadSms = jest.fn();
|
||||
const uploaded = await refreshNotificationAuthorizations(
|
||||
"did:ethr:0xme",
|
||||
{ notifyingNewActivitySmsTime: "whenever" },
|
||||
{
|
||||
notifyingNewActivityTime: "sometime",
|
||||
notifyingNewActivitySmsTime: "whenever",
|
||||
},
|
||||
["fcm", "sms"],
|
||||
cursors,
|
||||
{},
|
||||
{ uploadFcm, uploadSms },
|
||||
);
|
||||
expect(uploadFcm).not.toHaveBeenCalled();
|
||||
expect(uploadSms).not.toHaveBeenCalled();
|
||||
expect(uploaded).toEqual({});
|
||||
});
|
||||
|
||||
it("survives a thrown upload", async () => {
|
||||
const uploaded = await refreshNotificationAuthorizations(
|
||||
"did:ethr:0xme",
|
||||
{ notifyingNewActivityTime: "9:00 AM" },
|
||||
["fcm", "sms"],
|
||||
cursors,
|
||||
{},
|
||||
|
||||
@@ -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>
|
||||
<!-- 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">
|
||||
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
||||
Troubleshoot your in-app notifications…
|
||||
@@ -1102,6 +1147,7 @@ import {
|
||||
configureNativeFetcherIfReady,
|
||||
deleteSmsPhone,
|
||||
getNotificationApiBaseUrl,
|
||||
initializeNativePushAndFirebaseMessaging,
|
||||
listSmsPhones,
|
||||
localTimeTextToUtc,
|
||||
revokeInAppNewActivity,
|
||||
@@ -1111,6 +1157,12 @@ import {
|
||||
UnsupportedIdentityError,
|
||||
} from "@/services/notifications";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
import { serverPushSupported } from "@/services/notifications/firebaseMessagingClient";
|
||||
import {
|
||||
effectiveNewActivityDelivery,
|
||||
type NewActivityDelivery,
|
||||
} from "@/services/notifications/newActivityDelivery";
|
||||
import { unregisterDevicesFromNotificationApi } from "@/services/notifications/NotificationService";
|
||||
import type { AlertSearchUploadState } from "@/interfaces/notifyApi";
|
||||
import {
|
||||
type CursorPair,
|
||||
@@ -1223,6 +1275,12 @@ export default class AccountViewView extends Vue {
|
||||
// Notification properties
|
||||
notifyingNewActivity: boolean = false;
|
||||
notifyingNewActivityTime: string = "";
|
||||
/** The in-app delivery that runs; see newActivityDelivery.ts. */
|
||||
newActivityDelivery: NewActivityDelivery = "server";
|
||||
/** False in a build without Firebase, where only "device" can run. */
|
||||
serverPushAvailable = true;
|
||||
deliveryChangeInProgress = false;
|
||||
isIos = Capacitor.getPlatform() === "ios";
|
||||
// SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes.
|
||||
// Settings hold what the service cannot report back: it has a route to list
|
||||
// registered numbers but none to read an authorization, so the chosen time
|
||||
@@ -1421,6 +1479,11 @@ export default class AccountViewView extends Vue {
|
||||
!!settings.searchBoxes && settings.searchBoxes.length > 0;
|
||||
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
||||
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
||||
this.serverPushAvailable = await serverPushSupported();
|
||||
this.newActivityDelivery = effectiveNewActivityDelivery(
|
||||
settings,
|
||||
this.serverPushAvailable,
|
||||
);
|
||||
this.notifyingNewActivitySmsPhone =
|
||||
settings.notifyingNewActivitySmsPhone || "";
|
||||
this.notifyingNewActivitySmsTime =
|
||||
@@ -1611,17 +1674,13 @@ export default class AccountViewView extends Vue {
|
||||
});
|
||||
this.notifyingNewActivity = true;
|
||||
this.notifyingNewActivityTime = timeText;
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await this.scheduleNewActivityDualNotification(timeText);
|
||||
if (this.newActivityDelivery === "device") {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await this.scheduleNewActivityDualNotification(timeText);
|
||||
}
|
||||
} else {
|
||||
await this.uploadServerNewActivity(timeText);
|
||||
}
|
||||
const searchState = await this.newActivityStartState("fcm");
|
||||
await authorizeInAppNewActivity(
|
||||
(message) => {
|
||||
this.reportAlertSearchAuthorizationFailure("upload", message);
|
||||
},
|
||||
undefined,
|
||||
searchState,
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -1644,12 +1703,27 @@ export default class AccountViewView extends Vue {
|
||||
* @returns false when the revoke failed and nothing changed
|
||||
*/
|
||||
private async turnOffInAppNewActivity(): Promise<boolean> {
|
||||
const revoked = await revokeInAppNewActivity((message) => {
|
||||
this.reportAlertSearchAuthorizationFailure("revoke", message);
|
||||
});
|
||||
if (!revoked) {
|
||||
return false;
|
||||
if (this.newActivityDelivery === "server") {
|
||||
const revoked = await revokeInAppNewActivity((message) => {
|
||||
this.reportAlertSearchAuthorizationFailure("revoke", message);
|
||||
});
|
||||
if (!revoked) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// also in server mode, to clear a device schedule set up before the
|
||||
// delivery choice existed
|
||||
await this.cancelDeviceNewActivity();
|
||||
await this.$saveSettings({
|
||||
notifyingNewActivityTime: "",
|
||||
});
|
||||
this.notifyingNewActivity = false;
|
||||
this.notifyingNewActivityTime = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Cancel the on-device New Activity schedule, if the plugin has one. */
|
||||
private async cancelDeviceNewActivity(): Promise<void> {
|
||||
if (Capacitor.isNativePlatform() && DailyNotification?.cancelDualSchedule) {
|
||||
try {
|
||||
await DailyNotification.cancelDualSchedule();
|
||||
@@ -1657,12 +1731,92 @@ export default class AccountViewView extends Vue {
|
||||
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
|
||||
}
|
||||
}
|
||||
await this.$saveSettings({
|
||||
notifyingNewActivityTime: "",
|
||||
});
|
||||
this.notifyingNewActivity = false;
|
||||
this.notifyingNewActivityTime = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this device for pushes and upload a batch counting from now, at
|
||||
* `timeText`. A deliberate reset, so it may fire today even after today's
|
||||
* push went.
|
||||
*
|
||||
* @returns false when the upload failed (the failure was reported)
|
||||
*/
|
||||
private async uploadServerNewActivity(timeText: string): Promise<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,
|
||||
async (success: boolean, timeText: string) => {
|
||||
if (!success) return;
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
if (this.newActivityDelivery === "server") {
|
||||
// The service schedules the push from its batch, so a new time
|
||||
// means a new batch. It keeps its cursors, so nothing waiting for
|
||||
// the next push is dropped, and it may fire today: a new time is a
|
||||
// reset.
|
||||
const uploaded = await authorizeInAppNewActivity(
|
||||
(message) => {
|
||||
this.reportAlertSearchAuthorizationFailure("upload", message);
|
||||
},
|
||||
undefined,
|
||||
alertSearchUploadState(
|
||||
buildNewActivityContext(
|
||||
this.axios,
|
||||
this.activeDid,
|
||||
await this.$accountSettings(),
|
||||
),
|
||||
undefined,
|
||||
),
|
||||
{ timeText, rerunToday: true },
|
||||
);
|
||||
if (!uploaded.ok) {
|
||||
return;
|
||||
}
|
||||
} else if (Capacitor.isNativePlatform()) {
|
||||
const time24h = this.parseTimeTo24Hour(timeText);
|
||||
const config = buildDualScheduleConfig({ notifyTime: time24h });
|
||||
const plugin = DailyNotification as unknown as {
|
||||
|
||||
Reference in New Issue
Block a user