Devices/Magic: - Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic - Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar) - Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung - Realtime auf user_devices → Settings aktualisiert Magic-Bindings live - Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie): - magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill) - Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook), per Resend-Mail + in-Response - Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert) Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
7.3 KiB
TypeScript
203 lines
7.3 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,
|
|
});
|
|
// 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<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]);
|
|
}
|