diag(calls): add VoIP+push-token+ring-target logs; fix /call mount race
- AppDelegate: NSLog for didUpdate token, didInvalidate, didReceiveIncomingPush - backend/push: log [push-token] register, [call-ring] receiver token-counts + expo-push-fanout for android-fallback - app/call.tsx: 250ms grace window before closeScreen on initial idle (fixes 'foreground call flashes briefly then disappears' race when dm.tsx startCall set() hasn't propagated through useCallStore selector yet)
This commit is contained in:
parent
43eeeb3716
commit
7fae4539ae
@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AppState, I18nManager } from 'react-native';
|
||||
import { AppState, I18nManager, Platform } from 'react-native';
|
||||
|
||||
I18nManager.allowRTL(true);
|
||||
import { Stack, router } from 'expo-router';
|
||||
@ -112,11 +112,14 @@ function RootLayoutInner() {
|
||||
} else if (data.type === 'room' && data.targetId) {
|
||||
router.push({ pathname: '/room', params: { roomId: data.targetId } });
|
||||
} else if (data.type === 'call' && data.callId && data.from) {
|
||||
// Eingehender Anruf — Realtime hat (vermutlich) keine Subscription
|
||||
// gehabt weil App im Background war. Wir simulieren receiveIncoming
|
||||
// damit der Standard-Accept/Decline-Flow greift. Falls der Caller in
|
||||
// der Zwischenzeit aufgelegt hat: ring-cancel kommt sobald Channel
|
||||
// subscribed, dann teardown.
|
||||
// Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn
|
||||
// kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad
|
||||
// der die App im Background wachrüttelt — und der landet NICHT hier
|
||||
// (geht via AppDelegate → useCallKeepEvents). Wenn der User stattdessen
|
||||
// einen verpassten-Anruf-Push tappt, ist der Call längst beendet —
|
||||
// wir würden ihn künstlich auferwecken und die App zeigt einen Geist-
|
||||
// /call-Screen. Deshalb auf iOS hier nichts tun.
|
||||
if (Platform.OS === 'ios') return;
|
||||
try {
|
||||
useCallStore.getState().receiveIncoming(data.callId, data.from);
|
||||
router.push('/call');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@ -32,21 +32,46 @@ export default function CallScreen() {
|
||||
const clear = useCallStore((s) => s._clear);
|
||||
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
// Race-Guard: zustand-set in dm.tsx → router.push('/call') passiert manchmal
|
||||
// bevor unser useCallStore-Selector den neuen status sieht. Beim allerersten
|
||||
// Render kann status also kurz 'idle' sein, obwohl gerade ein Call gestartet
|
||||
// wird. Wir geben dem Store 250ms Zeit bevor wir bei 'idle' den Screen
|
||||
// schlie\u00dfen \u2014 sonst flickert die Call-UI sofort weg ("kurz und verschwindet").
|
||||
const mountedAt = useRef(Date.now());
|
||||
|
||||
// Kein aktiver Call → Screen schließen.
|
||||
// Helper: zur\u00fcck oder Fallback zu Home, wenn kein Back-Stack vorhanden
|
||||
// (z.B. wenn /call via VoIP-PushKit / Deep-Link als Initial-Route ge\u00f6ffnet wurde).
|
||||
const closeScreen = () => {
|
||||
if (router.canGoBack()) router.back();
|
||||
else router.replace('/');
|
||||
};
|
||||
|
||||
// Kein aktiver Call \u2192 Screen schlie\u00dfen.
|
||||
useEffect(() => {
|
||||
if (status === 'idle') router.back();
|
||||
}, [status, router]);
|
||||
if (status !== 'idle') return;
|
||||
const sinceMount = Date.now() - mountedAt.current;
|
||||
if (sinceMount < 250) {
|
||||
// Initial-Mount-Race: noch warten ob startCall/receiveIncoming den status
|
||||
// gleich auf outgoing/incoming setzt.
|
||||
const tm = setTimeout(() => {
|
||||
if (useCallStore.getState().status === 'idle') closeScreen();
|
||||
}, 250 - sinceMount);
|
||||
return () => clearTimeout(tm);
|
||||
}
|
||||
closeScreen();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status]);
|
||||
|
||||
// Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen.
|
||||
useEffect(() => {
|
||||
if (status !== 'ended') return;
|
||||
const tm = setTimeout(() => {
|
||||
clear();
|
||||
router.back();
|
||||
closeScreen();
|
||||
}, 1300);
|
||||
return () => clearTimeout(tm);
|
||||
}, [status, clear, router]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, clear]);
|
||||
|
||||
// Gesprächsdauer-Timer.
|
||||
useEffect(() => {
|
||||
|
||||
@ -176,11 +176,21 @@ export default function SettingsScreen() {
|
||||
const pushEnabled = useNotificationPrefsStore((s) => s.pushEnabled);
|
||||
const streakReminderEnabled = useNotificationPrefsStore((s) => s.streakReminderEnabled);
|
||||
const streakReminderTime = useNotificationPrefsStore((s) => s.streakReminderTime);
|
||||
const callsInRecents = useNotificationPrefsStore((s) => s.callsInRecents);
|
||||
const setPushEnabled = useNotificationPrefsStore((s) => s.setPushEnabled);
|
||||
const setStreakReminderEnabled = useNotificationPrefsStore((s) => s.setStreakReminderEnabled);
|
||||
const setStreakReminderTime = useNotificationPrefsStore((s) => s.setStreakReminderTime);
|
||||
const setCallsInRecents = useNotificationPrefsStore((s) => s.setCallsInRecents);
|
||||
const initNotifPrefs = useNotificationPrefsStore((s) => s.init);
|
||||
|
||||
const onToggleCallsInRecents = async (value: boolean) => {
|
||||
await setCallsInRecents(value);
|
||||
Alert.alert(
|
||||
t('settings.calls_in_recents'),
|
||||
t('settings.calls_in_recents_restart'),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initNotifPrefs();
|
||||
}, []);
|
||||
@ -377,6 +387,19 @@ export default function SettingsScreen() {
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
...(Platform.OS === 'ios'
|
||||
? [
|
||||
{
|
||||
icon: 'call-outline' as const,
|
||||
label: t('settings.calls_in_recents'),
|
||||
sublabel: t('settings.calls_in_recents_desc'),
|
||||
toggle: {
|
||||
value: callsInRecents,
|
||||
onValueChange: onToggleCallsInRecents,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -14,23 +14,35 @@
|
||||
*/
|
||||
import { Platform, PermissionsAndroid } from 'react-native';
|
||||
import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep';
|
||||
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
|
||||
|
||||
let didSetup = false;
|
||||
|
||||
export async function setupCallKeep(): Promise<void> {
|
||||
if (didSetup) return;
|
||||
try {
|
||||
// Stelle sicher dass die User-Prefs aus AsyncStorage geladen sind, bevor
|
||||
// wir CXProviderConfiguration einfrieren. CallKit liest includesCallsInRecents
|
||||
// nur beim provider-init \u2014 nachtr\u00e4gliche \u00c4nderungen erfordern App-Restart.
|
||||
try {
|
||||
await useNotificationPrefsStore.getState().init();
|
||||
} catch {}
|
||||
const callsInRecents =
|
||||
useNotificationPrefsStore.getState().callsInRecents ?? false;
|
||||
|
||||
await RNCallKeep.setup({
|
||||
ios: {
|
||||
appName: 'ReBreak-Audio',
|
||||
// KEIN imageName → Default-Avatar von CallKit; wir setzen den echten
|
||||
// KEIN imageName \u2192 Default-Avatar von CallKit; wir setzen den echten
|
||||
// Caller-Namen via displayIncomingCall(localizedCallerName)
|
||||
supportsVideo: false,
|
||||
maximumCallGroups: '1',
|
||||
maximumCallsPerCallGroup: '1',
|
||||
// Privacy: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync, kein
|
||||
// Leak an Apple). Für DiGA mit Suchterkrankungs-Zielgruppe Pflicht.
|
||||
includesCallsInRecents: false,
|
||||
// Privacy-Default: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync).
|
||||
// F\u00fcr DiGA mit Suchterkrankungs-Zielgruppe Default. User kann
|
||||
// in Settings opt-in (\u2192 dann erscheint ReBreak in der nativen
|
||||
// \"Anrufe\"-App wie WhatsApp).
|
||||
includesCallsInRecents: callsInRecents,
|
||||
},
|
||||
android: {
|
||||
alertTitle: 'Anrufe in ReBreak zulassen',
|
||||
|
||||
@ -927,6 +927,9 @@
|
||||
"notifications_streak_time_picker_desc": "Stunde und Minute für die tägliche Streak-Erinnerung.",
|
||||
"notifications_hour": "Stunde",
|
||||
"notifications_minute": "Minute",
|
||||
"calls_in_recents": "Anrufe in iOS-Anrufliste",
|
||||
"calls_in_recents_desc": "ReBreak-Anrufe in der nativen \"Anrufe\"-App anzeigen. Achtung: Anrufliste wird via iCloud synchronisiert.",
|
||||
"calls_in_recents_restart": "Diese Einstellung wird beim nächsten App-Start wirksam.",
|
||||
"section_help": "Hilfe & Support",
|
||||
"help_faq": "FAQ",
|
||||
"help_faq_desc": "Häufige Fragen zur App",
|
||||
|
||||
@ -925,6 +925,9 @@
|
||||
"notifications_streak_time_picker_desc": "Select hour and minute for your daily streak reminder.",
|
||||
"notifications_hour": "Hour",
|
||||
"notifications_minute": "Minute",
|
||||
"calls_in_recents": "Show calls in iOS Recents",
|
||||
"calls_in_recents_desc": "Show ReBreak calls in the native Phone app. Warning: call list is synced via iCloud.",
|
||||
"calls_in_recents_restart": "This setting takes effect on next app launch.",
|
||||
"section_help": "Help & Support",
|
||||
"help_faq": "FAQ",
|
||||
"help_faq_desc": "Common questions about the app",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { AppState, NativeModules } from 'react-native';
|
||||
import { AppState, NativeModules, Platform } from 'react-native';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { apiFetch } from '../lib/api';
|
||||
@ -37,6 +37,13 @@ export type CallPeer = { id: string; nickname: string; avatar: string | null };
|
||||
export type CallEndReason = 'declined' | 'ended' | 'failed' | 'unanswered' | 'busy' | null;
|
||||
|
||||
const UNANSWERED_MS = 35_000;
|
||||
// Callee-seitiges Auto-Cancel: Wenn ein eingehender Anruf in dieser Zeit
|
||||
// weder angenommen noch durch Caller-cancel beendet wird (z.B. weil die App
|
||||
// im Background war, der Caller schon aufgelegt hat und das ring-cancel-
|
||||
// broadcast wegen fehlender Realtime-Subscription nicht ankam), räumen wir
|
||||
// den stale 'incoming'-State auf — sonst sieht der User beim nächsten App-
|
||||
// Open einen "Geist-Anruf".
|
||||
const INCOMING_TIMEOUT_MS = 45_000;
|
||||
|
||||
// Nicht-reaktive Handles (gehören nicht in den Zustand-State).
|
||||
let pc: any = null;
|
||||
@ -44,6 +51,7 @@ let localStream: any = null;
|
||||
let callChan: RealtimeChannel | null = null;
|
||||
let pendingRemoteIce: any[] = [];
|
||||
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let incomingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let selfMe: CallPeer | null = null; // für Caller-Side DM-Logging
|
||||
let currentRole: 'caller' | 'callee' | null = null;
|
||||
let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log
|
||||
@ -98,6 +106,7 @@ function fireRingCancel(peerId: string, callId: string) {
|
||||
|
||||
function teardown() {
|
||||
if (unansweredTimer) { clearTimeout(unansweredTimer); unansweredTimer = null; }
|
||||
if (incomingTimer) { clearTimeout(incomingTimer); incomingTimer = null; }
|
||||
try { pc?.close?.(); } catch {}
|
||||
try { localStream?.getTracks?.().forEach((t: any) => t.stop()); } catch {}
|
||||
if (callChan) { supabase.removeChannel(callChan); callChan = null; }
|
||||
@ -384,14 +393,30 @@ export const useCallStore = create<CallState>((set, get) => {
|
||||
set({ status: 'incoming', peer: from, callId, muted: false, speaker: false, startedAt: null, endReason: null });
|
||||
// CallKit-/ConnectionService-UI hochziehen — ABER nur wenn App NICHT im
|
||||
// Vordergrund. Im Foreground kümmert sich der In-App /call-Screen darum,
|
||||
// sonst gibt es Doppel-UI (System-Banner + Fullscreen). VoIP-Push-Pfad
|
||||
// (AppDelegate.reportNewIncomingCall) läuft eh getrennt, das deduliziert
|
||||
// CallKit intern via UUID.
|
||||
if (AppState.currentState !== 'active') {
|
||||
// sonst gibt es Doppel-UI (System-Banner + Fullscreen).
|
||||
//
|
||||
// iOS-Hinweis: Bei VoIP-Push hat AppDelegate.swift bereits
|
||||
// `RNCallKeep.reportNewIncomingCall` aufgerufen — wir dürfen das hier
|
||||
// NICHT erneut tun (gleiche UUID → CallKit zeigt Banner doppelt /
|
||||
// verhält sich kaputt). Auf iOS gibt es keinen sinnvollen JS-Pfad
|
||||
// für Background-Ring, da Supabase-Realtime im Background nicht läuft;
|
||||
// jeder bg-Ring kommt zwingend via PushKit → AppDelegate.
|
||||
if (Platform.OS !== 'ios' && AppState.currentState !== 'active') {
|
||||
try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {}
|
||||
} else {
|
||||
clog('receiveIncoming: app foreground — skipping CallKit UI (using in-app /call screen)');
|
||||
clog('receiveIncoming: skipping JS-side CallKit show (foreground OR iOS-AppDelegate already handled)');
|
||||
}
|
||||
// Auto-Cancel-Timer: Wenn nach 45s noch immer 'incoming' (kein Accept,
|
||||
// kein Caller-Cancel angekommen), als 'unanswered' aufräumen damit nicht
|
||||
// beim nächsten App-Open ein toter Call-Screen sichtbar wird.
|
||||
if (incomingTimer) clearTimeout(incomingTimer);
|
||||
incomingTimer = setTimeout(() => {
|
||||
const st = get();
|
||||
if (st.status === 'incoming' && st.callId === callId) {
|
||||
clog('receiveIncoming: stale incoming timeout → hangup(unanswered)');
|
||||
st.hangup('unanswered');
|
||||
}
|
||||
}, INCOMING_TIMEOUT_MS);
|
||||
// CallKit (iOS) + ConnectionService (Android) spielen ihren eigenen
|
||||
// Ringtone — KEIN InCallManager.startRingtone() hier, sonst doppeltes
|
||||
// Klingeln. InCallManager.start() bleibt aus demselben Grund weg; der
|
||||
|
||||
@ -8,15 +8,20 @@ type NotificationPrefsState = {
|
||||
pushEnabled: boolean;
|
||||
streakReminderEnabled: boolean;
|
||||
streakReminderTime: { hour: number; minute: number };
|
||||
/** iOS-CallKit: ReBreak-Calls in nat. "Anrufe"-App + iCloud-Sync.
|
||||
* Default false (DSGVO/Art.9 — Sucht-Zielgruppe). Opt-in via Settings.
|
||||
* Änderung wird erst nach App-Neustart wirksam. */
|
||||
callsInRecents: boolean;
|
||||
|
||||
init: () => Promise<void>;
|
||||
setPushEnabled: (value: boolean) => Promise<void>;
|
||||
setStreakReminderEnabled: (value: boolean) => Promise<void>;
|
||||
setStreakReminderTime: (hour: number, minute: number) => Promise<void>;
|
||||
setCallsInRecents: (value: boolean) => Promise<void>;
|
||||
reset: () => Promise<void>;
|
||||
};
|
||||
|
||||
async function persist(patch: Partial<Pick<NotificationPrefsState, 'pushEnabled' | 'streakReminderEnabled' | 'streakReminderTime'>>) {
|
||||
async function persist(patch: Partial<Pick<NotificationPrefsState, 'pushEnabled' | 'streakReminderEnabled' | 'streakReminderTime' | 'callsInRecents'>>) {
|
||||
const existing = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
const current = existing ? JSON.parse(existing) : {};
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, ...patch }));
|
||||
@ -26,6 +31,7 @@ export const useNotificationPrefsStore = create<NotificationPrefsState>((set, ge
|
||||
pushEnabled: true,
|
||||
streakReminderEnabled: false,
|
||||
streakReminderTime: { hour: 9, minute: 0 },
|
||||
callsInRecents: false,
|
||||
|
||||
init: async () => {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
@ -38,6 +44,7 @@ export const useNotificationPrefsStore = create<NotificationPrefsState>((set, ge
|
||||
pushEnabled: parsed.pushEnabled ?? true,
|
||||
streakReminderEnabled: parsed.streakReminderEnabled ?? false,
|
||||
streakReminderTime: parsed.streakReminderTime ?? { hour: 9, minute: 0 },
|
||||
callsInRecents: parsed.callsInRecents ?? false,
|
||||
});
|
||||
},
|
||||
|
||||
@ -65,8 +72,13 @@ export const useNotificationPrefsStore = create<NotificationPrefsState>((set, ge
|
||||
await persist({ streakReminderTime });
|
||||
},
|
||||
|
||||
setCallsInRecents: async (value) => {
|
||||
set({ callsInRecents: value });
|
||||
await persist({ callsInRecents: value });
|
||||
},
|
||||
|
||||
reset: async () => {
|
||||
set({ pushEnabled: false, streakReminderEnabled: false, streakReminderTime: { hour: 9, minute: 0 } });
|
||||
set({ pushEnabled: false, streakReminderEnabled: false, streakReminderTime: { hour: 9, minute: 0 }, callsInRecents: false });
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
} catch {}
|
||||
|
||||
@ -34,6 +34,12 @@ export default defineEventHandler(async (event) => {
|
||||
const { token, platform, deviceId, voipToken } = parsed.data;
|
||||
const db = usePrisma();
|
||||
|
||||
console.log(
|
||||
`[push-token] register user=${user.id.slice(0,8)} platform=${platform} ` +
|
||||
`token=${token.slice(0,25)}\u2026 voip=${voipToken ? voipToken.slice(0,16)+'\u2026' : 'none'} ` +
|
||||
`device=${deviceId ?? 'none'}`,
|
||||
);
|
||||
|
||||
await db.pushToken.upsert({
|
||||
where: { token },
|
||||
create: {
|
||||
|
||||
@ -169,6 +169,11 @@ export async function sendCallRingPush(payload: CallRingPushPayload): Promise<vo
|
||||
where: { userId: payload.receiverId, enabled: true },
|
||||
select: { id: true, token: true, voipToken: true, platform: true },
|
||||
});
|
||||
console.log(
|
||||
`[call-ring] receiver=${payload.receiverId.slice(0,8)} tokens=${tokens.length} ` +
|
||||
`(ios=${tokens.filter(t=>t.platform==='ios').length}/voip=${tokens.filter(t=>t.platform==='ios'&&t.voipToken).length}, ` +
|
||||
`android=${tokens.filter(t=>t.platform==='android').length})`,
|
||||
);
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
// ─── 1) VoIP-Pushes (iOS, CallKit-Wake-from-killed-State) ─────────────
|
||||
@ -230,6 +235,7 @@ export async function sendCallRingPush(payload: CallRingPushPayload): Promise<vo
|
||||
}
|
||||
|
||||
if (messages.length === 0) return;
|
||||
console.log(`[call-ring] expo-push to ${messages.length} non-voip token(s) for receiver=${payload.receiverId.slice(0,8)}`);
|
||||
|
||||
const chunks = expo.chunkPushNotifications(messages);
|
||||
for (const chunk of chunks) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user