- AppDelegate: NSLog for didUpdate token, didInvalidate, didReceiveIncomingPush - backend/push: log [push-token] register, [call-ring] receiver token-counts + expo-push-fanout for android-fallback - app/call.tsx: 250ms grace window before closeScreen on initial idle (fixes 'foreground call flashes briefly then disappears' race when dm.tsx startCall set() hasn't propagated through useCallStore selector yet)
165 lines
6.3 KiB
TypeScript
165 lines
6.3 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';
|
|
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
|
|
|
|
let didSetup = false;
|
|
|
|
export async function setupCallKeep(): Promise<void> {
|
|
if (didSetup) 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 {}
|
|
}
|
|
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"
|
|
// 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 };
|