Add push notifications via local sync loop and FCM
Local notifications: - NotificationHelper posts per-room notifications from the sync loop - Notifications suppressed when room is active (ChatScreen DisposableEffect) - Deferred for encrypted rooms until room key arrives; fires with real body - Notification tap navigates to the room via pendingRoomId StateFlow - POST_NOTIFICATIONS permission requested at runtime (API 33+) FCM push (app killed): - MatrixFirebaseMessagingService handles token refresh and data messages - Pusher registered with homeserver pointing to Cloudflare Worker gateway - gateway/src/index.js: Cloudflare Worker forwards Matrix pushes to FCM v1 API using a service-account JWT signed with Web Crypto - last_sync_token persisted in SharedPreferences so restored sessions start with an incremental sync and fire notifications immediately (fixes missed-on-wake bug) - Default FCM notification channel declared in manifest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1f5e6ca691
commit
de6386d614
15 changed files with 489 additions and 5 deletions
143
gateway/src/index.js
Normal file
143
gateway/src/index.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Matrix push gateway — receives pushes from the Matrix homeserver and forwards to FCM.
|
||||
// Runs on Cloudflare Workers (free tier: 100k requests/day).
|
||||
//
|
||||
// Required secrets (set via `wrangler secret put`):
|
||||
// FIREBASE_PROJECT_ID — e.g. "matrix-f7c28"
|
||||
// FIREBASE_SERVICE_ACCOUNT — full JSON content of the Firebase service account key
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
if (request.method !== "POST") {
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response("Invalid JSON", { status: 400 });
|
||||
}
|
||||
|
||||
const notification = body?.notification;
|
||||
if (!notification) {
|
||||
return new Response("Missing notification", { status: 400 });
|
||||
}
|
||||
|
||||
const devices = notification.devices ?? [];
|
||||
const rejected = [];
|
||||
|
||||
let accessToken;
|
||||
try {
|
||||
accessToken = await getFcmAccessToken(env.FIREBASE_SERVICE_ACCOUNT);
|
||||
} catch (e) {
|
||||
console.error("OAuth2 failed:", e.message);
|
||||
return new Response("Gateway auth error", { status: 500 });
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
const token = device.pushkey;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://fcm.googleapis.com/v1/projects/${env.FIREBASE_PROJECT_ID.trim()}/messages:send`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
token,
|
||||
data: {
|
||||
room_id: notification.room_id ?? "",
|
||||
sender: notification.sender ?? "",
|
||||
event_id: notification.event_id ?? "",
|
||||
},
|
||||
android: { priority: "HIGH" },
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.error("FCM error:", await resp.text());
|
||||
rejected.push(token);
|
||||
} else {
|
||||
console.log("FCM ok:", await resp.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("FCM send failed:", e.message);
|
||||
rejected.push(token);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({ rejected }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Generate a Google OAuth2 access token from a service account JSON using Web Crypto.
|
||||
async function getFcmAccessToken(serviceAccountJson) {
|
||||
const sa = JSON.parse(serviceAccountJson);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const header = { alg: "RS256", typ: "JWT" };
|
||||
const payload = {
|
||||
iss: sa.client_email,
|
||||
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
};
|
||||
|
||||
const b64url = (obj) =>
|
||||
btoa(JSON.stringify(obj))
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const signingInput = `${b64url(header)}.${b64url(payload)}`;
|
||||
|
||||
const pkcs8 = Uint8Array.from(
|
||||
atob(
|
||||
sa.private_key
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/, "")
|
||||
.replace(/-----END PRIVATE KEY-----/, "")
|
||||
.replace(/\n/g, "")
|
||||
),
|
||||
(c) => c.charCodeAt(0)
|
||||
);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
pkcs8,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
|
||||
const sig = await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const encodedSig = btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const jwt = `${signingInput}.${encodedSig}`;
|
||||
|
||||
const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
|
||||
});
|
||||
|
||||
const tokenData = await tokenResp.json();
|
||||
if (!tokenData.access_token) throw new Error(JSON.stringify(tokenData));
|
||||
return tokenData.access_token;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue