chahinebrini 822053e11e feat(calls): CallKit/ConnectionService + VoIP-PushKit + EU-Ringback
Caller/Callee UX:
- lib/ringback.ts + assets/sounds/ringback_eu.mp3 (EU 425Hz Festnetz-Tone)
- stores/call.ts: stopRingback bei connected, hangup-reasons, logCallToChat fix
- locales: 'Wird angerufen…' statt 'Ruft an…'

CallKit (iOS) + ConnectionService (Android):
- lib/callkit.ts: setupCallKeep, displayIncomingCall, startOutgoingCall, reportConnected/Ended (appName 'ReBreak-Audio', includesCallsInRecents=false für DSGVO/DiGA)
- hooks/useCallKeepEvents.ts: native answer/end/mute → useCallStore-Actions
- stores/call.ts: CallKit-Aufrufe an allen lifecycle-Punkten
- app.config.ts: @config-plugins/react-native-callkeep + UIBackgroundModes voip/audio + Android-Telecom-Perms

VoIP-PushKit Backend:
- services/voip-push.ts: @parse/node-apn Provider mit .p12 (Topic org.rebreak.app.voip)
- services/push.ts sendCallRingPush: feuert beide Pfade (VoIP iOS + Expo Android/Fallback)
- prisma: push_tokens.voip_token Column + Migration 20260604
- api/users/me/push-token: optional voipToken im Body
- Env (Infisical): APNS_VOIP_P12_PATH/PASSWORD/TOPIC/PRODUCTION

Push-tap routing + cold-start handling:
- app/_layout.tsx: type:'call' Push → useCallStore.receiveIncoming + /call

Docs: ops/CALLKIT_SETUP.md (Apple-Portal-Steps für VoIP-Cert)
2026-06-04 09:27:13 +02:00

248 lines
8.2 KiB
TypeScript

/**
* Push-Notifications via Expo Server SDK.
*
* Verwendet von:
* - backend/server/db/chat.ts sendDirectMessage() → triggert nach Insert
* - backend/server/db/chat-rooms.ts createRoomMessage() → triggert nach Insert
*
* Versand ist fire-and-forget (kein Block des HTTP-Response): Errors werden geloggt
* aber nicht propagiert, damit Chat-Send nicht failed wenn Expo-Server down ist.
*
* Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch
* disabled (PushToken.enabled = false) — Re-Enable nur durch Re-Registrierung
* vom Client.
*/
import { Expo, type ExpoPushMessage } from "expo-server-sdk";
import { usePrisma } from "../utils/prisma";
import { sendVoIPPush } from "./voip-push";
const expo = new Expo();
export interface ChatPushPayload {
/** Empfänger-User-ID (kann mehrere Tokens haben) */
receiverId: string;
/** Sender-Display-Name (für Titel "Max sendete: …") */
senderName: string;
/** Nachrichten-Vorschau (max ~100 Zeichen) */
preview: string;
/** Deep-Link-Daten — Client navigiert beim Tap */
data: {
type: "dm" | "room";
/** Bei DM: senderId, bei Room: roomId */
targetId: string;
messageId: string;
};
}
export async function sendChatPush(payload: ChatPushPayload): Promise<void> {
try {
const db = usePrisma();
// 1) Profile-Opt-out prüfen
const profile = await db.profile.findUnique({
where: { id: payload.receiverId },
select: { chatPushEnabled: true, deletedAt: true },
});
if (!profile || profile.deletedAt || !profile.chatPushEnabled) {
return;
}
// 2) Alle aktiven Tokens des Receivers
const tokens = await db.pushToken.findMany({
where: { userId: payload.receiverId, enabled: true },
select: { id: true, token: true },
});
if (tokens.length === 0) return;
// 3) Messages bauen (nur valide Expo-Tokens)
const messages: ExpoPushMessage[] = [];
const validTokenIds: string[] = [];
for (const t of tokens) {
if (!Expo.isExpoPushToken(t.token)) {
// Ungültiger Token-Format → disablen
await db.pushToken
.update({ where: { id: t.id }, data: { enabled: false } })
.catch(() => {});
continue;
}
messages.push({
to: t.token,
sound: "default",
title: payload.senderName,
body: payload.preview,
data: payload.data,
// Channel-ID für Android — wird in app.config.ts/native registriert
channelId: "chat",
});
validTokenIds.push(t.id);
}
if (messages.length === 0) return;
// 4) Senden in Chunks (Expo akzeptiert max 100 pro Request)
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
await expo.sendPushNotificationsAsync(chunk);
} catch (err) {
console.error("[push] chunk send failed:", err);
}
}
// 5) lastUsedAt bumpen (best-effort)
await db.pushToken
.updateMany({
where: { id: { in: validTokenIds } },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
// Hinweis: Receipt-Polling (DeviceNotRegistered → token disablen) wird in
// einem separaten Cron-Job gemacht (TODO: scripts/push-receipts-cleanup.ts).
// Für MVP: Tokens bleiben aktiv bis sie explizit vom Client deregistriert
// werden oder Expo dauerhaft DeviceNotRegistered meldet.
} catch (err) {
console.error("[push] sendChatPush failed:", err);
}
}
/**
* Helper für Username-Lookup (für Push-Titel).
* Fällt auf "Jemand" zurück wenn kein nickname/username gesetzt.
*/
export async function getDisplayName(userId: string): Promise<string> {
const db = usePrisma();
const p = await db.profile.findUnique({
where: { id: userId },
select: { nickname: true, username: true },
});
return p?.nickname || p?.username || "Jemand";
}
/**
* Truncate für Push-Body (Expo hat 4kb hard limit, aber UI zeigt eh nur ~2 Zeilen).
*/
export function truncatePreview(text: string, max = 100): string {
if (text.length <= max) return text;
return text.slice(0, max - 1) + "…";
}
export interface CallRingPushPayload {
/** Empfänger (Callee) — bekommt den Push */
receiverId: string;
/** Caller-Display-Name (für Titel) */
callerName: string;
/** Caller-Profil (für Show-Screen nach Tap) */
callerId: string;
callerNickname: string;
callerAvatar: string | null;
/** Call-ID — muss matchen damit Callee an die richtige Session andocken kann */
callId: string;
}
/**
* Push für eingehenden Voice-Call.
*
* Foreground-Calls funktionieren via Supabase Realtime; dieser Push deckt den
* Background/locked-screen-Fall ab. Beim Tap navigiert der Client direkt in
* den /call-Screen und triggert `useCall.receiveIncoming(callId, from)` —
* die normale Accept/Decline-UI greift dann.
*
* Wichtig: KEIN VoIPPushKit (Apple-only, braucht CallKit). Wir nutzen den
* regulären APNs-Alert-Push mit `priority: high` + Notifications-Sound.
* Trade-off: Auf iOS wacht ein force-quit-killed Process NICHT auf — das ist
* dasselbe Verhalten wie reguläre Chat-Pushes. Für echte CallKit-Integration
* → Phase 2 (eigene Initiative).
*/
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
try {
const db = usePrisma();
const profile = await db.profile.findUnique({
where: { id: payload.receiverId },
select: { chatPushEnabled: true, deletedAt: true },
});
if (!profile || profile.deletedAt || !profile.chatPushEnabled) return;
const tokens = await db.pushToken.findMany({
where: { userId: payload.receiverId, enabled: true },
select: { id: true, token: true, voipToken: true, platform: true },
});
if (tokens.length === 0) return;
// ─── 1) VoIP-Pushes (iOS, CallKit-Wake-from-killed-State) ─────────────
// Läuft parallel zum regulären Push. Wenn voipToken NULL ist (Android,
// alte iOS-Builds ohne PushKit-Setup) wird das Device hier übersprungen
// und fällt auf den normalen Lockscreen-Banner zurück.
for (const t of tokens) {
if (t.platform === "ios" && t.voipToken) {
void sendVoIPPush({
voipToken: t.voipToken,
callId: payload.callId,
callerName: payload.callerName,
callerId: payload.callerId,
callerNickname: payload.callerNickname,
callerAvatar: payload.callerAvatar,
});
}
}
// ─── 2) Reguläre Expo-Pushes (Android FCM + iOS-Fallback) ────────────
const messages: ExpoPushMessage[] = [];
const validTokenIds: string[] = [];
for (const t of tokens) {
if (!Expo.isExpoPushToken(t.token)) {
await db.pushToken
.update({ where: { id: t.id }, data: { enabled: false } })
.catch(() => {});
continue;
}
messages.push({
to: t.token,
sound: "default",
title: `📞 ${payload.callerName}`,
body: "Eingehender Anruf",
priority: "high",
// Android: dedizierter Calls-Channel (im Client mit Importance.MAX +
// Vibration + Bypass-DND zu konfigurieren)
channelId: "calls",
// iOS: zeigt als alert auch im Lockscreen (interruption-level)
// 'time-sensitive' wäre besser, braucht aber Critical-Alerts-Entitlement.
// 'active' (default) reicht für Phase 1.
data: {
type: "call",
callId: payload.callId,
from: {
id: payload.callerId,
nickname: payload.callerNickname,
avatar: payload.callerAvatar,
},
},
});
validTokenIds.push(t.id);
}
if (messages.length === 0) return;
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
await expo.sendPushNotificationsAsync(chunk);
} catch (err) {
console.error("[push] call-ring chunk send failed:", err);
}
}
await db.pushToken
.updateMany({
where: { id: { in: validTokenIds } },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
} catch (err) {
console.error("[push] sendCallRingPush failed:", err);
}
}