diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index 09dbc87..91e9a25 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -99,6 +99,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ "@config-plugins/react-native-webrtc", // CallKit (iOS) + ConnectionService (Android) — native Call-UI mit Wake-aus-killed-State "@config-plugins/react-native-callkeep", + // VoIP-PushKit-Bridge im AppDelegate — überlebt prebuild --clean + "./plugins/with-voip-pushkit-ios", [ "expo-media-library", { diff --git a/apps/rebreak-native/hooks/useCallKeepEvents.ts b/apps/rebreak-native/hooks/useCallKeepEvents.ts index d0e3449..70710a5 100644 --- a/apps/rebreak-native/hooks/useCallKeepEvents.ts +++ b/apps/rebreak-native/hooks/useCallKeepEvents.ts @@ -8,17 +8,58 @@ * Wird einmal app-weit im _layout.tsx aufgerufen. */ import { useEffect } from 'react'; +import { Platform } from 'react-native'; import { useRouter } from 'expo-router'; import RNCallKeep from 'react-native-callkeep'; import { useCallStore } from '../stores/call'; import { setupCallKeep } from '../lib/callkit'; +// VoIP-PushKit (iOS only) — Payload-Empfang um peer-Info in den Store zu +// hydrieren, BEVOR User in der CallKit-UI auf "Annehmen" tippt. +let RNVoipPushNotification: any = null; +if (Platform.OS === 'ios') { + try { + RNVoipPushNotification = require('react-native-voip-push-notification').default; + } catch { + // ungelinkt — skip + } +} + export function useCallKeepEvents() { const router = useRouter(); useEffect(() => { void setupCallKeep(); + // VoIP-Push-Payload (iOS) → Store hydrieren mit caller-Info, damit + // acceptCall() den richtigen peer kennt. Der CallKit-UI-Show ist bereits + // in AppDelegate.swift (reportNewIncomingCall) erfolgt. + const onVoipNotification = (payload: any) => { + console.log('[voip] incoming push payload', payload); + const st = useCallStore.getState(); + const callId = String(payload?.callId ?? ''); + const from = payload?.from; + if (!callId || !from?.id) return; + // receiveIncoming dedupliziert via status-Check → safe doppelt zu rufen. + st.receiveIncoming(callId, { + id: String(from.id), + nickname: String(from.nickname ?? payload?.callerName ?? 'ReBreak'), + avatar: from.avatar ?? null, + }); + }; + if (RNVoipPushNotification) { + RNVoipPushNotification.addEventListener('notification', onVoipNotification); + // didLoadWithEvents wird beim Cold-Start gefeuert mit den queued Events, + // die vor dem ersten addEventListener-Call eingegangen sind. + RNVoipPushNotification.addEventListener('didLoadWithEvents', (events: any[]) => { + for (const ev of events ?? []) { + if (ev?.name === 'RNVoipPushRemoteNotificationReceivedEvent') { + onVoipNotification(ev?.data); + } + } + }); + } + // User tippt "Annehmen" in der CallKit-/ConnectionService-UI const onAnswer = ({ callUUID }: { callUUID: string }) => { console.log('[callkeep] answer', callUUID); @@ -57,6 +98,10 @@ export function useCallKeepEvents() { RNCallKeep.removeEventListener('answerCall'); RNCallKeep.removeEventListener('endCall'); RNCallKeep.removeEventListener('didPerformSetMutedCallAction'); + if (RNVoipPushNotification) { + RNVoipPushNotification.removeEventListener('notification'); + RNVoipPushNotification.removeEventListener('didLoadWithEvents'); + } }; }, [router]); } diff --git a/apps/rebreak-native/hooks/usePushTokenRegistration.ts b/apps/rebreak-native/hooks/usePushTokenRegistration.ts index a06e9b8..6d41d23 100644 --- a/apps/rebreak-native/hooks/usePushTokenRegistration.ts +++ b/apps/rebreak-native/hooks/usePushTokenRegistration.ts @@ -18,7 +18,47 @@ import Constants from 'expo-constants'; import { apiFetch } from '../lib/api'; import { getDeviceId } from '../lib/deviceId'; +// VoIP-PushKit (iOS only) — separater Push-Token für CallKit-Wake-from-killed. +// Lazy require: react-native-voip-push-notification ist iOS-only, Android-Bundle +// soll nicht crashen. +let RNVoipPushNotification: any = null; +if (Platform.OS === 'ios') { + try { + RNVoipPushNotification = require('react-native-voip-push-notification').default; + } catch { + // Modul (noch) nicht gelinkt — z.B. im Expo-Go-Fallback. Silent skip. + } +} + const lastRegisteredToken: { current: string | null } = { current: null }; +const lastRegisteredVoipToken: { current: string | null } = { current: null }; + +/** Holt iOS-VoIP-Push-Token via PushKit. Resolve mit `null` wenn nicht verfügbar. */ +async function fetchVoipToken(): Promise { + if (Platform.OS !== 'ios' || !RNVoipPushNotification) return null; + return new Promise((resolve) => { + let resolved = false; + const onToken = (token: string) => { + if (resolved) return; + resolved = true; + resolve(token || null); + }; + try { + // Listener registrieren BEVOR registerVoipToken — sonst race. + RNVoipPushNotification.addEventListener('register', onToken); + RNVoipPushNotification.registerVoipToken(); + // Safety-Timeout: nach 4s aufgeben (Cert/Provisioning fehlt etc.). + setTimeout(() => { + if (resolved) return; + resolved = true; + resolve(null); + }, 4000); + } catch (err) { + if (__DEV__) console.warn('[voip] register failed:', err); + resolve(null); + } + }); +} export async function registerPushTokenWithBackend(): Promise { // Simulator/Emulator support: Expo Push funktioniert auf physical devices only @@ -74,10 +114,18 @@ export async function registerPushTokenWithBackend(): Promise { const token = tokenData.data; if (!token) return null; - // 4) Idempotenz-Skip: wenn schon registriert in dieser Session, nicht nochmal - if (lastRegisteredToken.current === token) return token; + // 4) VoIP-Token parallel holen (iOS-only, no-op auf Android). + const voipToken = await fetchVoipToken(); - // 5) Senden an Backend + // 5) Idempotenz-Skip: wenn beide Tokens unverändert, kein Re-POST. + if ( + lastRegisteredToken.current === token && + lastRegisteredVoipToken.current === voipToken + ) { + return token; + } + + // 6) Senden an Backend const deviceId = await getDeviceId(); await apiFetch('/api/users/me/push-token', { method: 'POST', @@ -85,11 +133,16 @@ export async function registerPushTokenWithBackend(): Promise { token, platform: Platform.OS as 'ios' | 'android', deviceId, + ...(voipToken ? { voipToken } : {}), }, }); lastRegisteredToken.current = token; - if (__DEV__) console.log('[push] token registered:', token.slice(0, 30) + '…'); + lastRegisteredVoipToken.current = voipToken; + if (__DEV__) { + console.log('[push] token registered:', token.slice(0, 30) + '…'); + if (voipToken) console.log('[voip] token registered:', voipToken.slice(0, 30) + '…'); + } return token; } catch (err) { console.warn('[push] registration failed:', err); diff --git a/apps/rebreak-native/plugins/with-voip-pushkit-ios.js b/apps/rebreak-native/plugins/with-voip-pushkit-ios.js new file mode 100644 index 0000000..6cf1279 --- /dev/null +++ b/apps/rebreak-native/plugins/with-voip-pushkit-ios.js @@ -0,0 +1,205 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/** + * VoIP-PushKit + CallKit Bridge im iOS-AppDelegate. + * + * Patcht beim `expo prebuild`: + * 1) ReBreak-Bridging-Header.h + * → exposed RNVoipPushNotificationManager + RNCallKeep an Swift. + * 2) AppDelegate.swift + * → `import PushKit` + * → PKPushRegistry init in didFinishLaunchingWithOptions + * → PKPushRegistryDelegate-Extension: didUpdatePushCredentials forwarded an + * RNVoipPushNotificationManager; didReceiveIncomingPushWithPayload meldet + * sofort eingehenden Anruf an RNCallKeep (iOS 13+ Anforderung: MUSS in + * derselben run-loop CXProvider.reportNewIncomingCall aufrufen, sonst + * killt iOS die App). + * + * react-native-voip-push-notification hat KEIN config-plugin → ohne diesen + * Patch greift `pnpm expo prebuild --clean` jedes Mal die Edits ab. + * + * Idempotent via Marker. Läuft als withDangerousMod('ios'), nach prebuild, + * vor pod install. + */ +const { withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +const MARKER = '// REBREAK_VOIP_PUSHKIT'; + +// ───────────────────────────────────────────────────── Bridging-Header + +const BRIDGING_PATCH = ` +${MARKER} +#import "RNVoipPushNotificationManager.h" +#import "RNCallKeep.h" +${MARKER} +`; + +// ───────────────────────────────────────────────────── AppDelegate.swift + +const IMPORT_PATCH = `${MARKER} +import PushKit +${MARKER} +`; + +// In didFinishLaunchingWithOptions — direkt vor `return super.application(...)`. +const REGISTRY_INIT = ` + ${MARKER} + // PushKit-Registry für VoIP-Push (CallKit). MUSS in didFinishLaunching + // initialisiert werden, sonst kommt der erste Push nach App-Cold-Start nicht + // an. + let voipRegistry = PKPushRegistry(queue: nil) + voipRegistry.desiredPushTypes = [.voIP] + voipRegistry.delegate = self + ${MARKER} +`; + +// PKPushRegistryDelegate-Extension am Ende der Datei. +const DELEGATE_EXTENSION = ` + +${MARKER} +// MARK: - VoIP PushKit + CallKit Bridge +extension AppDelegate: PKPushRegistryDelegate { + + // Device-Token vom System nach voIP-Registrierung. + public func pushRegistry( + _ registry: PKPushRegistry, + didUpdate pushCredentials: PKPushCredentials, + for type: PKPushType + ) { + RNVoipPushNotificationManager.didUpdate(pushCredentials, forType: type.rawValue) + } + + // Token-Invalidierung — aktuell no-op; Client refresht beim nächsten Boot. + public func pushRegistry( + _ registry: PKPushRegistry, + didInvalidatePushTokenFor type: PKPushType + ) { + // nothing + } + + // Eingehender VoIP-Push. MUSS auf iOS 13+ in derselben run-loop + // CXProvider.reportNewIncomingCall aufrufen, sonst killt iOS die App + // ("VoIP push received but app didn't post incoming call"). + public func pushRegistry( + _ registry: PKPushRegistry, + didReceiveIncomingPushWith payload: PKPushPayload, + for type: PKPushType, + completion: @escaping () -> Void + ) { + let dict = payload.dictionaryPayload + let callId = (dict["callId"] as? String) ?? UUID().uuidString + let callerName = (dict["callerName"] as? String) ?? "ReBreak" + let handle = (dict["handle"] as? String) ?? callerName + + // 1) Sofort an CallKit melden (iOS-Pflicht). uuid MUSS deterministisch sein, + // damit der JS-Layer den Call mit demselben UUID antworten/auflegen kann. + RNCallKeep.reportNewIncomingCall( + callId, + handle: handle, + handleType: "generic", + hasVideo: false, + localizedCallerName: callerName, + supportsHolding: false, + supportsDTMF: false, + supportsGrouping: false, + supportsUngrouping: false, + fromPushKit: true, + payload: dict, + withCompletionHandler: completion + ) + + // 2) Forward an JS — wenn die App schon im Vordergrund war, kann sie auf + // didLoadWithEvents reagieren (Auto-Navigation /call). + RNVoipPushNotificationManager.didReceiveIncomingPush(with: payload, forType: type.rawValue) + } +} +${MARKER} +`; + +// ───────────────────────────────────────────────────────────────────── + +function patchBridgingHeader(iosRoot) { + // Bridging-Header heißt analog zum Target — bei uns "ReBreak-Bridging-Header.h". + const candidates = fs + .readdirSync(iosRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .flatMap((d) => { + const p = path.join(iosRoot, d.name); + return fs.existsSync(p) + ? fs + .readdirSync(p) + .filter((f) => f.endsWith('-Bridging-Header.h')) + .map((f) => path.join(p, f)) + : []; + }); + + if (candidates.length === 0) { + console.warn('[with-voip-pushkit-ios] no *-Bridging-Header.h found — skipping bridging patch'); + return; + } + + for (const headerPath of candidates) { + let header = fs.readFileSync(headerPath, 'utf-8'); + if (header.includes(MARKER)) continue; + header += BRIDGING_PATCH; + fs.writeFileSync(headerPath, header); + console.log('[with-voip-pushkit-ios] patched bridging header:', path.basename(headerPath)); + } +} + +function patchAppDelegate(iosRoot) { + const candidates = fs + .readdirSync(iosRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .flatMap((d) => { + const p = path.join(iosRoot, d.name); + const file = path.join(p, 'AppDelegate.swift'); + return fs.existsSync(file) ? [file] : []; + }); + + if (candidates.length === 0) { + console.warn('[with-voip-pushkit-ios] AppDelegate.swift not found — skipping'); + return; + } + + for (const appDelegatePath of candidates) { + let src = fs.readFileSync(appDelegatePath, 'utf-8'); + if (src.includes(MARKER)) continue; + + // 1) Top-of-file: import PushKit (nach `import React` einfügen). + if (!src.includes('import PushKit')) { + src = src.replace(/(import React\n)/, `$1${IMPORT_PATCH}`); + } + + // 2) didFinishLaunching: PKPushRegistry direkt vor dem return-super injizieren. + const returnSuperRe = /(\n\s+return super\.application\(application, didFinishLaunchingWithOptions: launchOptions\))/; + if (returnSuperRe.test(src)) { + src = src.replace(returnSuperRe, `${REGISTRY_INIT}$1`); + } else { + console.warn('[with-voip-pushkit-ios] kein didFinishLaunching-return gefunden — Registry-Init NICHT eingefügt'); + } + + // 3) Delegate-Extension am Ende der Datei anhängen. + src += DELEGATE_EXTENSION; + + fs.writeFileSync(appDelegatePath, src); + console.log('[with-voip-pushkit-ios] patched AppDelegate.swift'); + } +} + +module.exports = function withVoipPushKitIOS(config) { + return withDangerousMod(config, [ + 'ios', + async (cfg) => { + const iosRoot = cfg.modRequest.platformProjectRoot; + try { + patchBridgingHeader(iosRoot); + patchAppDelegate(iosRoot); + } catch (e) { + console.error('[with-voip-pushkit-ios] patch failed:', e); + } + return cfg; + }, + ]); +};