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

135 lines
4.7 KiB
TypeScript

/**
* CallKit (iOS) / ConnectionService (Android) Wrapper.
*
* Zentralisiert react-native-callkeep so dass stores/call.ts platform-agnostic
* bleibt. Drei Verantwortungen:
* 1. setup() einmal beim App-Start (Permission-Prompt auf Android)
* 2. displayIncomingCall() wenn Push/Realtime ein Ring signalisiert
* 3. startCall() wenn der User selbst einen Anruf initiiert
*
* Privacy für DiGA (sensible Userbasis, Art. 9 DSGVO):
* - includesCallsInRecents: false → KEIN iCloud-Sync der Anrufliste
* - handle = userId (Email-Type) → keine Telefonnummern-Style-Anzeige
* - appName "ReBreak-Audio" → erscheint im Lockscreen-Banner
*/
import { Platform, PermissionsAndroid } from 'react-native';
import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep';
let didSetup = false;
export async function setupCallKeep(): Promise<void> {
if (didSetup) return;
try {
await RNCallKeep.setup({
ios: {
appName: 'ReBreak-Audio',
// KEIN imageName → Default-Avatar von CallKit; wir setzen den echten
// Caller-Namen via displayIncomingCall(localizedCallerName)
supportsVideo: false,
maximumCallGroups: '1',
maximumCallsPerCallGroup: '1',
// Privacy: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync, kein
// Leak an Apple). Für DiGA mit Suchterkrankungs-Zielgruppe Pflicht.
includesCallsInRecents: false,
},
android: {
alertTitle: 'Anrufe in ReBreak zulassen',
alertDescription:
'ReBreak braucht Telefon-Konto-Berechtigung, um eingehende Sprach-Anrufe wie in Telefon-Apps anzuzeigen.',
cancelButton: 'Nicht jetzt',
okButton: 'Erlauben',
// Foreground-Service für Android 11+ Mic-Background-Access
foregroundService: {
channelId: 'org.rebreak.app.calls',
channelName: 'ReBreak-Anrufe',
notificationTitle: 'ReBreak: Anruf aktiv',
},
},
});
if (Platform.OS === 'android') {
RNCallKeep.setAvailable(true);
// Android 14+: Full-Screen-Intent für Lockscreen-Call-UI braucht extra Permission
try {
await PermissionsAndroid.request(
// @ts-ignore — RN-Types haben USE_FULL_SCREEN_INTENT evtl. noch nicht
'android.permission.USE_FULL_SCREEN_INTENT' as any,
);
} catch {}
}
didSetup = true;
} catch (e: any) {
console.warn('[callkeep] setup failed', e?.message ?? e);
}
}
/**
* UUID-Generator — CallKit braucht lowercase UUID (Apple-Quirk).
* Wir mappen 1:1 callId ↔ callUUID (deterministic) damit beide Apps dieselbe ID nutzen.
*/
export function callIdToUuid(callId: string): string {
// CallId hat Format: "<timestamp>-<random8>", z.B. "1717491600000-abc12345"
// Wir bauen daraus eine deterministische UUID v4-Form via simple Hex-Padding.
const clean = callId.replace(/[^a-z0-9]/gi, '').toLowerCase().padEnd(32, '0').slice(0, 32);
return `${clean.slice(0, 8)}-${clean.slice(8, 12)}-4${clean.slice(13, 16)}-8${clean.slice(17, 20)}-${clean.slice(20, 32)}`;
}
export function displayIncomingCall(callId: string, callerName: string): void {
try {
const uuid = callIdToUuid(callId);
RNCallKeep.displayIncomingCall(
uuid,
callerName, // handle — wird im iOS-Lockscreen als Untertitel angezeigt
callerName, // localizedCallerName — als Titel
'generic', // handleType: kein Phone-Number-Format
false, // hasVideo
);
} catch (e: any) {
console.warn('[callkeep] displayIncomingCall failed', e?.message ?? e);
}
}
export function startOutgoingCall(callId: string, calleeName: string): void {
try {
const uuid = callIdToUuid(callId);
RNCallKeep.startCall(uuid, calleeName, calleeName, 'generic', false);
} catch (e: any) {
console.warn('[callkeep] startCall failed', e?.message ?? e);
}
}
export function reportConnected(callId: string): void {
try {
const uuid = callIdToUuid(callId);
if (Platform.OS === 'android') {
RNCallKeep.setCurrentCallActive(uuid);
}
} catch {}
}
export function endCall(callId: string): void {
try {
const uuid = callIdToUuid(callId);
RNCallKeep.endCall(uuid);
} catch {}
}
export function reportEnded(
callId: string,
reason: 'failed' | 'declined' | 'unanswered' | 'ended' = 'ended',
): void {
try {
const uuid = callIdToUuid(callId);
const reasonCode =
reason === 'failed'
? CK_CONSTANTS.END_CALL_REASONS.FAILED
: reason === 'unanswered'
? CK_CONSTANTS.END_CALL_REASONS.UNANSWERED
: reason === 'declined'
? CK_CONSTANTS.END_CALL_REASONS.MISSED
: CK_CONSTANTS.END_CALL_REASONS.REMOTE_ENDED;
RNCallKeep.reportEndCallWithUUID(uuid, reasonCode);
} catch {}
}
export { RNCallKeep };