/** * 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 { 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 { // 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, }); // Account-Security: „Neues Gerät verbunden" — eigener Channel, damit der // User Geräte-Alerts nicht zusammen mit Chat stummschaltet. await Notifications.setNotificationChannelAsync('devices', { name: 'Geräte & Sicherheit', importance: Notifications.AndroidImportance.HIGH, vibrationPattern: [0, 250, 250, 250], 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 { 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(null); useEffect(() => { if (!userId || userId === lastUserId.current) return; lastUserId.current = userId; void registerPushTokenWithBackend(); }, [userId]); }