/** * 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, NativeModules } from 'react-native'; import { useNotificationPrefsStore } from '../stores/notificationPrefs'; // react-native-callkeep ist ein natives Modul. In Builds OHNE das Modul (z.B. der // Dev-Build) crasht bereits der IMPORT, weil RNCallKeep beim Laden // `new NativeEventEmitter(nativeModule)` mit null aufruft (Invariant Violation → // die ganze App startet nicht). Darum laden wir das JS-Wrapper-Modul nur, wenn das // native Modul `RNCallKeep` wirklich vorhanden ist. Sonst sind alle Call-Funktionen // hier no-ops (Calls in diesem Build deaktiviert, App läuft trotzdem). export const CALLKEEP_AVAILABLE = !!(NativeModules as { RNCallKeep?: unknown }).RNCallKeep; // eslint-disable-next-line @typescript-eslint/no-explicit-any let RNCallKeep: any = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any let CK_CONSTANTS: any = null; if (CALLKEEP_AVAILABLE) { // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require const mod = require('react-native-callkeep'); RNCallKeep = mod.default ?? mod; CK_CONSTANTS = mod.CONSTANTS; } export function isCallKeepAvailable(): boolean { return CALLKEEP_AVAILABLE; } let didSetup = false; export async function setupCallKeep(): Promise { if (didSetup || !RNCallKeep) return; try { // Stelle sicher dass die User-Prefs aus AsyncStorage geladen sind, bevor // wir CXProviderConfiguration einfrieren. CallKit liest includesCallsInRecents // nur beim provider-init \u2014 nachtr\u00e4gliche \u00c4nderungen erfordern App-Restart. try { await useNotificationPrefsStore.getState().init(); } catch {} const callsInRecents = useNotificationPrefsStore.getState().callsInRecents ?? false; await RNCallKeep.setup({ ios: { appName: 'ReBreak-Audio', // KEIN imageName \u2192 Default-Avatar von CallKit; wir setzen den echten // Caller-Namen via displayIncomingCall(localizedCallerName) supportsVideo: false, maximumCallGroups: '1', maximumCallsPerCallGroup: '1', // Privacy-Default: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync). // F\u00fcr DiGA mit Suchterkrankungs-Zielgruppe Default. User kann // in Settings opt-in (\u2192 dann erscheint ReBreak in der nativen // \"Anrufe\"-App wie WhatsApp). includesCallsInRecents: callsInRecents, }, 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 {} } // HINWEIS: KEIN endAllCalls() hier. Bei einem VoIP-Cold-Start (App wird // DURCH den eingehenden Call gestartet) läuft setupCallKeep im selben Launch // wie der gerade reportete Incoming-Call → endAllCalls() würde genau diesen // legitimen Anruf killen (= Auto-Reject bei Force-Quit). Der Zombie-Cleanup // passiert stattdessen verzögert + bedingt in useCallKeepEvents (nur wenn der // Store nach einer Grace-Period keinen echten Call kennt). 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: "-", z.B. "1717491600000-abc12345" // CallKit/CXProvider braucht **valides Hex-UUID** (0-9a-f) — sonst native // crash beim startCall(). Wir mappen jedes Zeichen der callId auf einen // Hex-Digit via charCode-modulo. Deterministic. const hex: string[] = []; for (let i = 0; i < callId.length && hex.length < 32; i++) { const code = callId.charCodeAt(i); // 2 Hex-Digits pro Zeichen → genug Material für 32 Hex-Zeichen hex.push(((code >> 4) & 0xf).toString(16)); if (hex.length < 32) hex.push((code & 0xf).toString(16)); } while (hex.length < 32) hex.push('0'); const clean = hex.join('').slice(0, 32); // UUID v4 Format: xxxxxxxx-xxxx-4xxx-Yxxx-xxxxxxxxxxxx (Y ∈ {8,9,a,b}) 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 { // Android: NICHT aufrufen \u2014 `RNCallKeep.startCall` triggert // `telecomManager.placeCall` was die System-Telefon-Wahl-UI \u00f6ffnet. F\u00fcr // In-App-WebRTC-Calls v\u00f6llig falsch (User sieht "Telefon w\u00e4hlt\u2026" statt // unserem /call-Screen). iOS-CallKit macht das richtig (Audio-Session // setup, keine native UI weil supportsHolding=false und wir keine // CXEndCallAction triggern bis User auflegt). if (Platform.OS !== 'ios') return; 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 };