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

131 lines
4.0 KiB
TypeScript

/**
* iOS-VoIP-Push via PushKit + APNs HTTP/2.
*
* Unterschied zu services/push.ts (Expo Server SDK):
* - Reguläre Pushes laufen über Expo's Proxy (token = ExponentPushToken[xxx])
* - VoIP-Pushes MÜSSEN direkt an APNs gehen (Apple verbietet Proxy für PushKit)
* - Nutzt das VoIP-Services-Cert (.p12) das wir bei Apple beantragt haben
* - Wake-from-killed-State: einziger Weg ein iOS-App aufzuwecken um CallKit
* UI anzuzeigen
*
* Env-Vars (aus Infisical):
* APNS_VOIP_P12_PATH Pfad zur .p12-Datei (z.B. /root/.secrets/rebreak-voip.p12)
* APNS_VOIP_P12_PASSWORD Cert-Password
* APNS_VOIP_TOPIC Bundle-ID + ".voip" (org.rebreak.app.voip)
* APNS_VOIP_PRODUCTION "true" für Production-APNs-Endpoint
*
* Wenn ENV-Vars fehlen: Service no-op (kein Error, nur Log-Warnung beim ersten Send).
* → Macht reguläre Pushes nicht kaputt wenn das VoIP-Setup noch unfertig ist.
*/
import apn from "@parse/node-apn";
import fs from "node:fs";
let provider: apn.Provider | null = null;
let initialized = false;
let topic: string | null = null;
function getProvider(): apn.Provider | null {
if (initialized) return provider;
initialized = true;
const p12Path = process.env.APNS_VOIP_P12_PATH;
const p12Pass = process.env.APNS_VOIP_P12_PASSWORD;
const tpc = process.env.APNS_VOIP_TOPIC;
const production = process.env.APNS_VOIP_PRODUCTION === "true";
if (!p12Path || !p12Pass || !tpc) {
console.warn(
"[voip-push] disabled — missing env (APNS_VOIP_P12_PATH/PASSWORD/TOPIC)",
);
return null;
}
if (!fs.existsSync(p12Path)) {
console.warn(`[voip-push] disabled — p12 file not found at ${p12Path}`);
return null;
}
try {
topic = tpc;
provider = new apn.Provider({
pfx: p12Path,
passphrase: p12Pass,
production,
});
console.log(`[voip-push] initialized (topic=${tpc}, production=${production})`);
return provider;
} catch (err) {
console.error("[voip-push] init failed:", err);
return null;
}
}
export interface VoIPCallPayload {
/** VoIP-PushKit-Token des Empfänger-Devices (64-char hex, NICHT der Expo-Token) */
voipToken: string;
/** Eindeutige Call-ID (matcht callIdToUuid() im Client) */
callId: string;
/** Display-Name für CallKit-UI */
callerName: string;
/** User-ID des Anrufers — Client braucht das um peer-Profile zu fetchen */
callerId: string;
callerNickname: string;
callerAvatar: string | null;
}
/**
* Sendet einen VoIP-Push an genau ein iOS-Device.
*
* Apple-Vorgaben (verifiziert via developer.apple.com/documentation/pushkit):
* - apns-push-type: voip
* - apns-topic: bundle-id + ".voip" (NICHT der reguläre bundle-id)
* - apns-priority: 10
* - apns-expiration: 0 (kein Storage — wenn Device offline, verworfen)
*
* @returns true bei Erfolg (oder no-op wenn Service disabled), false bei Fehler.
*/
export async function sendVoIPPush(payload: VoIPCallPayload): Promise<boolean> {
const p = getProvider();
if (!p || !topic) return true; // no-op, regulärer Push übernimmt
const note = new apn.Notification();
note.topic = topic;
note.expiry = 0; // sofort verwerfen wenn Device unreachable
note.priority = 10;
note.pushType = "voip";
note.payload = {
type: "call",
callId: payload.callId,
callerName: payload.callerName,
from: {
id: payload.callerId,
nickname: payload.callerNickname,
avatar: payload.callerAvatar,
},
};
try {
const result = await p.send(note, payload.voipToken);
if (result.failed.length > 0) {
const f = result.failed[0];
console.warn(
`[voip-push] failed token=${payload.voipToken.slice(0, 8)}… reason=`,
f.response ?? f.error,
);
return false;
}
return true;
} catch (err) {
console.error("[voip-push] send threw:", err);
return false;
}
}
/** Cleanup bei Server-Shutdown — wichtig wegen HTTP/2-Verbindung an Apple. */
export function shutdownVoIPProvider(): void {
if (provider) {
provider.shutdown();
provider = null;
initialized = false;
}
}