rebreak-monorepo/apps/rebreak-native/hooks/usePushTokenRegistration.ts
chahinebrini 6a907cf89b fix(calls): sandbox/prod VoIP-push failover + foreground CallKit-UI suppress
- voip-push: build both APNs Provider (production+sandbox) and try each per
  token with memoization. Fixes BadDeviceToken on Xcode-Dev-Builds where the
  token is Sandbox-only.
- stores/call: only call callkit.displayIncomingCall when app NOT in foreground
  \u2014 in foreground the /call route handles ringing UI, otherwise double UI
  (system banner + fullscreen).
- patch react-native-callkeep: New-Arch TurboModule compatibility (no overloads,
  no Bundle params in @ReactMethod).
- pushTokenRegistration: more verbose [voip] diagnostics.
2026-06-04 19:42:44 +02:00

193 lines
6.8 KiB
TypeScript

/**
* Push-Token-Registration mit Expo.
*
* Flow:
* 1. Permission anfragen (falls noch nicht entschieden)
* 2. ExponentPushToken[xxx] via getExpoPushTokenAsync() holen
* 3. Token an Backend POST /api/users/me/push-token senden
* 4. Bei Logout: DELETE /api/users/me/push-token?token=…
*
* Wird einmal pro App-Start nach Login aufgerufen — idempotent durch
* upsert im Backend.
*/
import { useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
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<string | null> {
if (Platform.OS !== 'ios') {
if (__DEV__) console.log('[voip] skip (not iOS)');
return null;
}
if (!RNVoipPushNotification) {
console.warn('[voip] react-native-voip-push-notification NOT linked — PushKit token cannot be fetched. Did the with-voip-pushkit-ios.js plugin run + prebuild?');
return null;
}
return new Promise((resolve) => {
let resolved = false;
const onToken = (token: string) => {
if (resolved) return;
resolved = true;
if (__DEV__) console.log('[voip] register event fired, token:', token ? token.slice(0, 20) + '…' : '(empty)');
resolve(token || null);
};
try {
// Listener registrieren BEVOR registerVoipToken — sonst race.
RNVoipPushNotification.addEventListener('register', onToken);
RNVoipPushNotification.registerVoipToken();
if (__DEV__) console.log('[voip] registerVoipToken() called, waiting for callback…');
// Safety-Timeout: nach 4s aufgeben (Cert/Provisioning fehlt etc.).
setTimeout(() => {
if (resolved) return;
resolved = true;
console.warn('[voip] timeout after 4s — NO PushKit token received. Check: Push-Notifications-Cap + Background-Modes(voip) in Xcode entitlements, physical device, VoIP-services-certificate in Apple Portal.');
resolve(null);
}, 4000);
} catch (err) {
console.warn('[voip] register failed:', err);
resolve(null);
}
});
}
export async function registerPushTokenWithBackend(): Promise<string | null> {
// Simulator/Emulator support: Expo Push funktioniert auf physical devices only
// — Simulator gibt Permission denied. Wir loggen aber crashen nicht.
if (!Device.isDevice) {
if (__DEV__) console.log('[push] skipped (simulator)');
return null;
}
try {
// 1) Permission
const { status: existing } = await Notifications.getPermissionsAsync();
let status = existing;
if (existing !== 'granted') {
const { status: requested } = await Notifications.requestPermissionsAsync();
status = requested;
}
if (status !== 'granted') {
if (__DEV__) console.log('[push] permission denied');
return null;
}
// 2) Android-Channels (muss vor getExpoPushTokenAsync existieren)
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('chat', {
name: 'Chat-Nachrichten',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#007AFF',
sound: 'default',
});
// Dedizierter Channel für eingehende Anrufe — MAX importance, längere
// Vibration, bypasst DND nicht (das bräuchte Critical-Alert-Permission).
await Notifications.setNotificationChannelAsync('calls', {
name: 'Anrufe',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 500, 200, 500, 200, 500],
lightColor: '#007AFF',
sound: 'default',
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
});
}
// 3) Token holen
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
console.warn('[push] EAS projectId missing — token cannot be issued');
return null;
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
const token = tokenData.data;
if (!token) return null;
// 4) VoIP-Token parallel holen (iOS-only, no-op auf Android).
const voipToken = await fetchVoipToken();
// 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',
body: {
token,
platform: Platform.OS as 'ios' | 'android',
deviceId,
...(voipToken ? { voipToken } : {}),
},
});
lastRegisteredToken.current = token;
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) + '…');
} else if (Platform.OS === 'ios') {
console.warn('[voip] iOS without VoIP-token — incoming calls in background/killed will NOT wake the app. Backend will fall back to silent Expo-push.');
}
}
return token;
} catch (err) {
console.warn('[push] registration failed:', err);
return null;
}
}
export async function unregisterPushTokenFromBackend(): Promise<void> {
const token = lastRegisteredToken.current;
if (!token) return;
try {
await apiFetch(
`/api/users/me/push-token?token=${encodeURIComponent(token)}`,
{ method: 'DELETE' },
);
lastRegisteredToken.current = null;
} catch (err) {
console.warn('[push] unregister failed:', err);
}
}
/**
* Hook: registriert Push-Token sobald User authenticated ist.
* Verwendet in app/_layout.tsx nach Auth-Init.
*/
export function usePushTokenRegistration(userId: string | null | undefined) {
const lastUserId = useRef<string | null>(null);
useEffect(() => {
if (!userId || userId === lastUserId.current) return;
lastUserId.current = userId;
void registerPushTokenWithBackend();
}, [userId]);
}