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)
135 lines
4.7 KiB
TypeScript
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 };
|