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:
chahinebrini 2026-06-04 20:37:43 +02:00
parent 43eeeb3716
commit 7fae4539ae
10 changed files with 142 additions and 24 deletions

View File

@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AppState, I18nManager } from 'react-native'; import { AppState, I18nManager, Platform } from 'react-native';
I18nManager.allowRTL(true); I18nManager.allowRTL(true);
import { Stack, router } from 'expo-router'; import { Stack, router } from 'expo-router';
@ -112,11 +112,14 @@ function RootLayoutInner() {
} else if (data.type === 'room' && data.targetId) { } else if (data.type === 'room' && data.targetId) {
router.push({ pathname: '/room', params: { roomId: data.targetId } }); router.push({ pathname: '/room', params: { roomId: data.targetId } });
} else if (data.type === 'call' && data.callId && data.from) { } else if (data.type === 'call' && data.callId && data.from) {
// Eingehender Anruf — Realtime hat (vermutlich) keine Subscription // Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn
// gehabt weil App im Background war. Wir simulieren receiveIncoming // kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad
// damit der Standard-Accept/Decline-Flow greift. Falls der Caller in // der die App im Background wachrüttelt — und der landet NICHT hier
// der Zwischenzeit aufgelegt hat: ring-cancel kommt sobald Channel // (geht via AppDelegate → useCallKeepEvents). Wenn der User stattdessen
// subscribed, dann teardown. // 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 { try {
useCallStore.getState().receiveIncoming(data.callId, data.from); useCallStore.getState().receiveIncoming(data.callId, data.from);
router.push('/call'); router.push('/call');

View File

@ -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 { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@ -32,21 +32,46 @@ export default function CallScreen() {
const clear = useCallStore((s) => s._clear); const clear = useCallStore((s) => s._clear);
const [elapsed, setElapsed] = useState(0); 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(() => { useEffect(() => {
if (status === 'idle') router.back(); if (status !== 'idle') return;
}, [status, router]); 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. // Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen.
useEffect(() => { useEffect(() => {
if (status !== 'ended') return; if (status !== 'ended') return;
const tm = setTimeout(() => { const tm = setTimeout(() => {
clear(); clear();
router.back(); closeScreen();
}, 1300); }, 1300);
return () => clearTimeout(tm); return () => clearTimeout(tm);
}, [status, clear, router]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, clear]);
// Gesprächsdauer-Timer. // Gesprächsdauer-Timer.
useEffect(() => { useEffect(() => {

View File

@ -176,11 +176,21 @@ export default function SettingsScreen() {
const pushEnabled = useNotificationPrefsStore((s) => s.pushEnabled); const pushEnabled = useNotificationPrefsStore((s) => s.pushEnabled);
const streakReminderEnabled = useNotificationPrefsStore((s) => s.streakReminderEnabled); const streakReminderEnabled = useNotificationPrefsStore((s) => s.streakReminderEnabled);
const streakReminderTime = useNotificationPrefsStore((s) => s.streakReminderTime); const streakReminderTime = useNotificationPrefsStore((s) => s.streakReminderTime);
const callsInRecents = useNotificationPrefsStore((s) => s.callsInRecents);
const setPushEnabled = useNotificationPrefsStore((s) => s.setPushEnabled); const setPushEnabled = useNotificationPrefsStore((s) => s.setPushEnabled);
const setStreakReminderEnabled = useNotificationPrefsStore((s) => s.setStreakReminderEnabled); const setStreakReminderEnabled = useNotificationPrefsStore((s) => s.setStreakReminderEnabled);
const setStreakReminderTime = useNotificationPrefsStore((s) => s.setStreakReminderTime); const setStreakReminderTime = useNotificationPrefsStore((s) => s.setStreakReminderTime);
const setCallsInRecents = useNotificationPrefsStore((s) => s.setCallsInRecents);
const initNotifPrefs = useNotificationPrefsStore((s) => s.init); 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(() => { useEffect(() => {
initNotifPrefs(); 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,
},
},
]
: []),
], ],
}, },
{ {

View File

@ -14,23 +14,35 @@
*/ */
import { Platform, PermissionsAndroid } from 'react-native'; import { Platform, PermissionsAndroid } from 'react-native';
import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep'; import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep';
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
let didSetup = false; let didSetup = false;
export async function setupCallKeep(): Promise<void> { export async function setupCallKeep(): Promise<void> {
if (didSetup) return; if (didSetup) return;
try { 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({ await RNCallKeep.setup({
ios: { ios: {
appName: 'ReBreak-Audio', 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) // Caller-Namen via displayIncomingCall(localizedCallerName)
supportsVideo: false, supportsVideo: false,
maximumCallGroups: '1', maximumCallGroups: '1',
maximumCallsPerCallGroup: '1', maximumCallsPerCallGroup: '1',
// Privacy: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync, kein // Privacy-Default: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync).
// Leak an Apple). Für DiGA mit Suchterkrankungs-Zielgruppe Pflicht. // F\u00fcr DiGA mit Suchterkrankungs-Zielgruppe Default. User kann
includesCallsInRecents: false, // in Settings opt-in (\u2192 dann erscheint ReBreak in der nativen
// \"Anrufe\"-App wie WhatsApp).
includesCallsInRecents: callsInRecents,
}, },
android: { android: {
alertTitle: 'Anrufe in ReBreak zulassen', alertTitle: 'Anrufe in ReBreak zulassen',

View File

@ -927,6 +927,9 @@
"notifications_streak_time_picker_desc": "Stunde und Minute für die tägliche Streak-Erinnerung.", "notifications_streak_time_picker_desc": "Stunde und Minute für die tägliche Streak-Erinnerung.",
"notifications_hour": "Stunde", "notifications_hour": "Stunde",
"notifications_minute": "Minute", "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", "section_help": "Hilfe & Support",
"help_faq": "FAQ", "help_faq": "FAQ",
"help_faq_desc": "Häufige Fragen zur App", "help_faq_desc": "Häufige Fragen zur App",

View File

@ -925,6 +925,9 @@
"notifications_streak_time_picker_desc": "Select hour and minute for your daily streak reminder.", "notifications_streak_time_picker_desc": "Select hour and minute for your daily streak reminder.",
"notifications_hour": "Hour", "notifications_hour": "Hour",
"notifications_minute": "Minute", "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", "section_help": "Help & Support",
"help_faq": "FAQ", "help_faq": "FAQ",
"help_faq_desc": "Common questions about the app", "help_faq_desc": "Common questions about the app",

View File

@ -1,5 +1,5 @@
import { create } from 'zustand'; 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 type { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { apiFetch } from '../lib/api'; 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; export type CallEndReason = 'declined' | 'ended' | 'failed' | 'unanswered' | 'busy' | null;
const UNANSWERED_MS = 35_000; 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). // Nicht-reaktive Handles (gehören nicht in den Zustand-State).
let pc: any = null; let pc: any = null;
@ -44,6 +51,7 @@ let localStream: any = null;
let callChan: RealtimeChannel | null = null; let callChan: RealtimeChannel | null = null;
let pendingRemoteIce: any[] = []; let pendingRemoteIce: any[] = [];
let unansweredTimer: ReturnType<typeof setTimeout> | null = null; 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 selfMe: CallPeer | null = null; // für Caller-Side DM-Logging
let currentRole: 'caller' | 'callee' | null = null; let currentRole: 'caller' | 'callee' | null = null;
let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log
@ -98,6 +106,7 @@ function fireRingCancel(peerId: string, callId: string) {
function teardown() { function teardown() {
if (unansweredTimer) { clearTimeout(unansweredTimer); unansweredTimer = null; } if (unansweredTimer) { clearTimeout(unansweredTimer); unansweredTimer = null; }
if (incomingTimer) { clearTimeout(incomingTimer); incomingTimer = null; }
try { pc?.close?.(); } catch {} try { pc?.close?.(); } catch {}
try { localStream?.getTracks?.().forEach((t: any) => t.stop()); } catch {} try { localStream?.getTracks?.().forEach((t: any) => t.stop()); } catch {}
if (callChan) { supabase.removeChannel(callChan); callChan = null; } 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 }); set({ status: 'incoming', peer: from, callId, muted: false, speaker: false, startedAt: null, endReason: null });
// CallKit-/ConnectionService-UI hochziehen — ABER nur wenn App NICHT im // CallKit-/ConnectionService-UI hochziehen — ABER nur wenn App NICHT im
// Vordergrund. Im Foreground kümmert sich der In-App /call-Screen darum, // Vordergrund. Im Foreground kümmert sich der In-App /call-Screen darum,
// sonst gibt es Doppel-UI (System-Banner + Fullscreen). VoIP-Push-Pfad // sonst gibt es Doppel-UI (System-Banner + Fullscreen).
// (AppDelegate.reportNewIncomingCall) läuft eh getrennt, das deduliziert //
// CallKit intern via UUID. // iOS-Hinweis: Bei VoIP-Push hat AppDelegate.swift bereits
if (AppState.currentState !== 'active') { // `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 {} try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {}
} else { } 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 // CallKit (iOS) + ConnectionService (Android) spielen ihren eigenen
// Ringtone — KEIN InCallManager.startRingtone() hier, sonst doppeltes // Ringtone — KEIN InCallManager.startRingtone() hier, sonst doppeltes
// Klingeln. InCallManager.start() bleibt aus demselben Grund weg; der // Klingeln. InCallManager.start() bleibt aus demselben Grund weg; der

View File

@ -8,15 +8,20 @@ type NotificationPrefsState = {
pushEnabled: boolean; pushEnabled: boolean;
streakReminderEnabled: boolean; streakReminderEnabled: boolean;
streakReminderTime: { hour: number; minute: number }; 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>; init: () => Promise<void>;
setPushEnabled: (value: boolean) => Promise<void>; setPushEnabled: (value: boolean) => Promise<void>;
setStreakReminderEnabled: (value: boolean) => Promise<void>; setStreakReminderEnabled: (value: boolean) => Promise<void>;
setStreakReminderTime: (hour: number, minute: number) => Promise<void>; setStreakReminderTime: (hour: number, minute: number) => Promise<void>;
setCallsInRecents: (value: boolean) => Promise<void>;
reset: () => 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 existing = await AsyncStorage.getItem(STORAGE_KEY);
const current = existing ? JSON.parse(existing) : {}; const current = existing ? JSON.parse(existing) : {};
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, ...patch })); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, ...patch }));
@ -26,6 +31,7 @@ export const useNotificationPrefsStore = create<NotificationPrefsState>((set, ge
pushEnabled: true, pushEnabled: true,
streakReminderEnabled: false, streakReminderEnabled: false,
streakReminderTime: { hour: 9, minute: 0 }, streakReminderTime: { hour: 9, minute: 0 },
callsInRecents: false,
init: async () => { init: async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY); const stored = await AsyncStorage.getItem(STORAGE_KEY);
@ -38,6 +44,7 @@ export const useNotificationPrefsStore = create<NotificationPrefsState>((set, ge
pushEnabled: parsed.pushEnabled ?? true, pushEnabled: parsed.pushEnabled ?? true,
streakReminderEnabled: parsed.streakReminderEnabled ?? false, streakReminderEnabled: parsed.streakReminderEnabled ?? false,
streakReminderTime: parsed.streakReminderTime ?? { hour: 9, minute: 0 }, 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 }); await persist({ streakReminderTime });
}, },
setCallsInRecents: async (value) => {
set({ callsInRecents: value });
await persist({ callsInRecents: value });
},
reset: async () => { 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 { try {
await AsyncStorage.removeItem(STORAGE_KEY); await AsyncStorage.removeItem(STORAGE_KEY);
} catch {} } catch {}

View File

@ -34,6 +34,12 @@ export default defineEventHandler(async (event) => {
const { token, platform, deviceId, voipToken } = parsed.data; const { token, platform, deviceId, voipToken } = parsed.data;
const db = usePrisma(); 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({ await db.pushToken.upsert({
where: { token }, where: { token },
create: { create: {

View File

@ -169,6 +169,11 @@ export async function sendCallRingPush(payload: CallRingPushPayload): Promise<vo
where: { userId: payload.receiverId, enabled: true }, where: { userId: payload.receiverId, enabled: true },
select: { id: true, token: true, voipToken: true, platform: 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; if (tokens.length === 0) return;
// ─── 1) VoIP-Pushes (iOS, CallKit-Wake-from-killed-State) ───────────── // ─── 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; 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); const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) { for (const chunk of chunks) {