Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async function start(env: Record<string, string> = {}, 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));
Expand All @@ -51,8 +51,8 @@ function post(path: string, body: unknown, cap?: string) {
});
}

async function register(platform = "ios", token = IOS_TOKEN, env?: string): Promise<string> {
const res = await post("/v1/devices", { platform, token, env });
async function register(platform = "ios", token = IOS_TOKEN, env?: string, opens?: boolean): Promise<string> {
const res = await post("/v1/devices", { platform, token, env, opens });
assert.equal(res.status, 201);
return ((await res.json()) as { capability: string }).capability;
}
Expand Down Expand Up @@ -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<string, unknown> }).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);
Expand Down
7 changes: 4 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendResult>;
android?: (token: string, alert: Alert) => Promise<SendResult>;
android?: (token: string, alert: Alert, opens: boolean) => Promise<SendResult>;
}

const MAX_BODY = 8 * 1024;
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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) {
Expand Down
15 changes: 10 additions & 5 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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 (
Expand All @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions src/fcm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,12 +81,12 @@ export class FcmSender {
return this.access.value;
}

async send(deviceToken: string, alert: Alert, now = Date.now()): Promise<SendResult> {
async send(deviceToken: string, alert: Alert, opens = false, now = Date.now()): Promise<SendResult> {
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 };
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/senders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")!;
Expand Down
Loading