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:
ltadeu6 2026-05-17 19:55:59 -03:00
parent 1f5e6ca691
commit de6386d614
15 changed files with 489 additions and 5 deletions

143
gateway/src/index.js Normal file
View 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;
}