diff --git a/src/app.test.ts b/src/app.test.ts index 7b0d444..74d8c01 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -35,7 +35,7 @@ async function start(env: Record = {}, senders?: Senders) { const config = loadConfig({ PORT: "0", ...env }); const fake: Senders = senders ?? { ios: async (token, apnsEnv, alert) => (sent.push({ platform: "ios", token, env: apnsEnv, alert }), next), - android: async (token, alert) => (sent.push({ platform: "android", token, alert }), next), + android: async (token, alert, opens) => (sent.push({ platform: "android", token, alert, opens }), next), }; const { handle } = createHandler(config, store, fake, quiet); server = createServer((req, res) => void handle(req, res)); @@ -51,8 +51,8 @@ function post(path: string, body: unknown, cap?: string) { }); } -async function register(platform = "ios", token = IOS_TOKEN, env?: string): Promise { - const res = await post("/v1/devices", { platform, token, env }); +async function register(platform = "ios", token = IOS_TOKEN, env?: string, opens?: boolean): Promise { + const res = await post("/v1/devices", { platform, token, env, opens }); assert.equal(res.status, 201); return ((await res.json()) as { capability: string }).capability; } @@ -123,6 +123,30 @@ describe("push relay", () => { assert.equal(sent[0].alert.body, "New direct message"); }); + it("sends a data-only message to an android app that opens previews itself", async () => { + const preview = "AQ" + "x".repeat(60); + const opener = await register("android", FCM_TOKEN, undefined, true); + await post("/v1/push", { kind: "message", preview }, opener); + assert.equal(sent[0].opens, true); + const message = (fcmMessage("t", sent[0].alert, true) as { message: Record }).message; + assert.ok(!("notification" in message), "Android would show the fixed text itself and never wake the app"); + assert.deepEqual(message.data, { c: sent[0].alert.tag, p: preview, t: "Gryt", b: "New message" }); + }); + + it("keeps the shown notification for an older android app, and for a push with no preview", async () => { + const older = await register("android", FCM_TOKEN); + await post("/v1/push", { kind: "message", preview: "AQ" + "x".repeat(60) }, older); + assert.equal(sent[0].opens, false); + assert.ok("notification" in (fcmMessage("t", sent[0].alert, false) as { message: object }).message); + const bare = { title: "Gryt", body: "New message", tag: "0123456789abcdef" }; + assert.ok("notification" in (fcmMessage("t", bare, true) as { message: object }).message); + }); + + it("ignores opens on iOS, where the extension does the opening", async () => { + const cap = await register("ios", IOS_TOKEN, undefined, true); + assert.equal(store.get(hashCapability(cap))?.opens, false); + }); + it("refuses bad platforms, tokens and kinds", async () => { assert.equal((await post("/v1/devices", { platform: "web", token: IOS_TOKEN })).status, 400); assert.equal((await post("/v1/devices", { platform: "ios", token: "nothex" })).status, 400); diff --git a/src/app.ts b/src/app.ts index 4bb05ee..4e5c219 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,7 @@ import { alertFor, KINDS, PREVIEW_SHAPE, type Alert, type Kind, type SendResult export interface Senders { ios?: (token: string, env: ApnsEnv, alert: Alert) => Promise; - android?: (token: string, alert: Alert) => Promise; + android?: (token: string, alert: Alert, opens: boolean) => Promise; } const MAX_BODY = 8 * 1024; @@ -48,7 +48,8 @@ export function createHandler(config: Config, store: Store, senders: Senders, lo if (!registrations.take(ip, clock())) throw new HttpError(429, "too_many_registrations"); const cap = newCapability(); - store.add(hashCapability(cap), { platform, token: body.token, env }, clock()); + const opens = platform === "android" && body.opens === true; + store.add(hashCapability(cap), { platform, token: body.token, env, opens }, clock()); store.bump("registered", platform, "", clock()); sendJson(res, 201, { capability: cap }); } @@ -71,7 +72,7 @@ export function createHandler(config: Config, store: Store, senders: Senders, lo const alert = alertFor(kind, capabilityTag(cap), preview as string | undefined); const result = device.platform === "ios" ? await senders.ios?.(device.token, device.env, alert) - : await senders.android?.(device.token, alert); + : await senders.android?.(device.token, alert, device.opens === true); if (!result) throw new HttpError(503, `${device.platform}_unavailable`); if (result.ok) { diff --git a/src/db.ts b/src/db.ts index 70c5b6c..532671e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -20,6 +20,8 @@ export interface Device { platform: Platform; token: string; env: ApnsEnv; + /** An Android app that opens sealed previews itself, so it's sent data-only messages (GRYT-1698). */ + opens?: boolean; } /** @@ -43,6 +45,9 @@ export class Store { last_used_at INTEGER NOT NULL ) `); + // Added after the table shipped, so an existing push.db gets the column here. + const columns = this.db.prepare("PRAGMA table_info(devices)").all() as { name: string }[]; + if (!columns.some((c) => c.name === "opens")) this.db.exec("ALTER TABLE devices ADD COLUMN opens INTEGER NOT NULL DEFAULT 0"); // Kept apart from devices, so the totals outlive every device that made them. this.db.exec(` CREATE TABLE IF NOT EXISTS daily_counts ( @@ -63,15 +68,15 @@ export class Store { add(capHash: string, device: Device, now: number): void { this.db - .prepare("INSERT INTO devices (cap_hash, platform, token, env, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?)") - .run(capHash, device.platform, device.token, device.env, now, now); + .prepare("INSERT INTO devices (cap_hash, platform, token, env, opens, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run(capHash, device.platform, device.token, device.env, device.opens ? 1 : 0, now, now); } get(capHash: string): Device | null { - const row = this.db.prepare("SELECT platform, token, env FROM devices WHERE cap_hash = ?").get(capHash) as - | { platform: Platform; token: string; env: ApnsEnv } + const row = this.db.prepare("SELECT platform, token, env, opens FROM devices WHERE cap_hash = ?").get(capHash) as + | { platform: Platform; token: string; env: ApnsEnv; opens: number } | undefined; - return row ? { platform: row.platform, token: row.token, env: row.env } : null; + return row ? { platform: row.platform, token: row.token, env: row.env, opens: row.opens === 1 } : null; } touch(capHash: string, now: number): void { diff --git a/src/fcm.ts b/src/fcm.ts index ddb80f7..c61192b 100644 --- a/src/fcm.ts +++ b/src/fcm.ts @@ -15,7 +15,17 @@ export function fcmAssertion(config: FcmConfig, key: KeyObject, nowSec: number): return `${body}.${sign("sha256", Buffer.from(body), key).toString("base64url")}`; } -export function fcmMessage(deviceToken: string, alert: Alert): object { +export function fcmMessage(deviceToken: string, alert: Alert, opens = false): object { + // Data-only, so Android hands it to the app to open instead of showing the fixed text itself. + if (opens && alert.preview) { + return { + message: { + token: deviceToken, + android: { priority: "high" }, + data: { c: alert.tag, p: alert.preview, t: alert.title, b: alert.body }, + }, + }; + } return { message: { token: deviceToken, @@ -71,12 +81,12 @@ export class FcmSender { return this.access.value; } - async send(deviceToken: string, alert: Alert, now = Date.now()): Promise { + async send(deviceToken: string, alert: Alert, opens = false, now = Date.now()): Promise { try { const res = await this.fetchImpl(`${this.sendOrigin}/v1/projects/${this.config.projectId}/messages:send`, { method: "POST", headers: { authorization: `Bearer ${await this.accessToken(now)}`, "content-type": "application/json" }, - body: JSON.stringify(fcmMessage(deviceToken, alert)), + body: JSON.stringify(fcmMessage(deviceToken, alert, opens)), signal: AbortSignal.timeout(TIMEOUT_MS), }); if (res.ok) return { ok: true }; diff --git a/src/index.ts b/src/index.ts index 9740638..d6b418a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ const apns = config.apns ? new ApnsSender(config.apns) : null; if (apns) senders.ios = (token, env, alert) => apns.send(token, env, alert); if (config.fcm) { const fcm = new FcmSender(config.fcm); - senders.android = (token, alert) => fcm.send(token, alert); + senders.android = (token, alert, opens) => fcm.send(token, alert, opens); } const { handle, prune } = createHandler(config, store, senders, log); diff --git a/src/senders.test.ts b/src/senders.test.ts index c5f4531..7def4e6 100644 --- a/src/senders.test.ts +++ b/src/senders.test.ts @@ -91,8 +91,8 @@ describe("FCM", () => { }) as unknown as typeof fetch; const fcm = new FcmSender(config, fakeFetch, "https://google.test/token", "https://fcm.test"); - assert.deepEqual(await fcm.send("tok", alert, 1_000_000), { ok: true }); - assert.deepEqual(await fcm.send("tok", alert, 1_000_500), { ok: true }); + assert.deepEqual(await fcm.send("tok", alert, false, 1_000_000), { ok: true }); + assert.deepEqual(await fcm.send("tok", alert, false, 1_000_500), { ok: true }); assert.equal(calls.filter((c) => c.url.endsWith("/token")).length, 1); const assertion = new URLSearchParams(String(calls[0].init.body)).get("assertion")!;