feat(calls): B4 — VoIP-PushKit config-plugin + client voipToken + JS payload bridge
This commit is contained in:
parent
f8181d63b9
commit
4a520ba7c9
@ -99,6 +99,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
"@config-plugins/react-native-webrtc",
|
"@config-plugins/react-native-webrtc",
|
||||||
// CallKit (iOS) + ConnectionService (Android) — native Call-UI mit Wake-aus-killed-State
|
// CallKit (iOS) + ConnectionService (Android) — native Call-UI mit Wake-aus-killed-State
|
||||||
"@config-plugins/react-native-callkeep",
|
"@config-plugins/react-native-callkeep",
|
||||||
|
// VoIP-PushKit-Bridge im AppDelegate — überlebt prebuild --clean
|
||||||
|
"./plugins/with-voip-pushkit-ios",
|
||||||
[
|
[
|
||||||
"expo-media-library",
|
"expo-media-library",
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8,17 +8,58 @@
|
|||||||
* Wird einmal app-weit im _layout.tsx aufgerufen.
|
* Wird einmal app-weit im _layout.tsx aufgerufen.
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import RNCallKeep from 'react-native-callkeep';
|
import RNCallKeep from 'react-native-callkeep';
|
||||||
import { useCallStore } from '../stores/call';
|
import { useCallStore } from '../stores/call';
|
||||||
import { setupCallKeep } from '../lib/callkit';
|
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() {
|
export function useCallKeepEvents() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void setupCallKeep();
|
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
|
// User tippt "Annehmen" in der CallKit-/ConnectionService-UI
|
||||||
const onAnswer = ({ callUUID }: { callUUID: string }) => {
|
const onAnswer = ({ callUUID }: { callUUID: string }) => {
|
||||||
console.log('[callkeep] answer', callUUID);
|
console.log('[callkeep] answer', callUUID);
|
||||||
@ -57,6 +98,10 @@ export function useCallKeepEvents() {
|
|||||||
RNCallKeep.removeEventListener('answerCall');
|
RNCallKeep.removeEventListener('answerCall');
|
||||||
RNCallKeep.removeEventListener('endCall');
|
RNCallKeep.removeEventListener('endCall');
|
||||||
RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
|
RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
|
||||||
|
if (RNVoipPushNotification) {
|
||||||
|
RNVoipPushNotification.removeEventListener('notification');
|
||||||
|
RNVoipPushNotification.removeEventListener('didLoadWithEvents');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,47 @@ import Constants from 'expo-constants';
|
|||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { getDeviceId } from '../lib/deviceId';
|
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 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<string | null> {
|
||||||
|
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<string | null> {
|
export async function registerPushTokenWithBackend(): Promise<string | null> {
|
||||||
// Simulator/Emulator support: Expo Push funktioniert auf physical devices only
|
// Simulator/Emulator support: Expo Push funktioniert auf physical devices only
|
||||||
@ -74,10 +114,18 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
|
|||||||
const token = tokenData.data;
|
const token = tokenData.data;
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
// 4) Idempotenz-Skip: wenn schon registriert in dieser Session, nicht nochmal
|
// 4) VoIP-Token parallel holen (iOS-only, no-op auf Android).
|
||||||
if (lastRegisteredToken.current === token) return token;
|
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();
|
const deviceId = await getDeviceId();
|
||||||
await apiFetch('/api/users/me/push-token', {
|
await apiFetch('/api/users/me/push-token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -85,11 +133,16 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
|
|||||||
token,
|
token,
|
||||||
platform: Platform.OS as 'ios' | 'android',
|
platform: Platform.OS as 'ios' | 'android',
|
||||||
deviceId,
|
deviceId,
|
||||||
|
...(voipToken ? { voipToken } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
lastRegisteredToken.current = token;
|
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;
|
return token;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[push] registration failed:', err);
|
console.warn('[push] registration failed:', err);
|
||||||
|
|||||||
205
apps/rebreak-native/plugins/with-voip-pushkit-ios.js
Normal file
205
apps/rebreak-native/plugins/with-voip-pushkit-ios.js
Normal file
@ -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;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user