From 7fae4539ae768d01243dccd0cefb51ed56e9ce9c Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 4 Jun 2026 20:37:43 +0200 Subject: [PATCH] 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) --- apps/rebreak-native/app/_layout.tsx | 15 +++++--- apps/rebreak-native/app/call.tsx | 37 ++++++++++++++++--- apps/rebreak-native/app/settings.tsx | 23 ++++++++++++ apps/rebreak-native/lib/callkit.ts | 20 ++++++++-- apps/rebreak-native/locales/de.json | 3 ++ apps/rebreak-native/locales/en.json | 3 ++ apps/rebreak-native/stores/call.ts | 37 ++++++++++++++++--- .../stores/notificationPrefs.ts | 16 +++++++- .../server/api/users/me/push-token.post.ts | 6 +++ backend/server/services/push.ts | 6 +++ 10 files changed, 142 insertions(+), 24 deletions(-) diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 81aac0c..f4f636c 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -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'); diff --git a/apps/rebreak-native/app/call.tsx b/apps/rebreak-native/app/call.tsx index 9f11d19..5d9a8dc 100644 --- a/apps/rebreak-native/app/call.tsx +++ b/apps/rebreak-native/app/call.tsx @@ -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(() => { diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index cb8f207..dd8841f 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -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, + }, + }, + ] + : []), ], }, { diff --git a/apps/rebreak-native/lib/callkit.ts b/apps/rebreak-native/lib/callkit.ts index c89eba3..46ede73 100644 --- a/apps/rebreak-native/lib/callkit.ts +++ b/apps/rebreak-native/lib/callkit.ts @@ -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 { 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', diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 781e0da..afd4ceb 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 42b1457..57043fc 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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", diff --git a/apps/rebreak-native/stores/call.ts b/apps/rebreak-native/stores/call.ts index c6a5786..39aa44d 100644 --- a/apps/rebreak-native/stores/call.ts +++ b/apps/rebreak-native/stores/call.ts @@ -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 | null = null; +let incomingTimer: ReturnType | 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((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 diff --git a/apps/rebreak-native/stores/notificationPrefs.ts b/apps/rebreak-native/stores/notificationPrefs.ts index dd4a0a9..e3fa0f8 100644 --- a/apps/rebreak-native/stores/notificationPrefs.ts +++ b/apps/rebreak-native/stores/notificationPrefs.ts @@ -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; setPushEnabled: (value: boolean) => Promise; setStreakReminderEnabled: (value: boolean) => Promise; setStreakReminderTime: (hour: number, minute: number) => Promise; + setCallsInRecents: (value: boolean) => Promise; reset: () => Promise; }; -async function persist(patch: Partial>) { +async function persist(patch: Partial>) { 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((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((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((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 {} diff --git a/backend/server/api/users/me/push-token.post.ts b/backend/server/api/users/me/push-token.post.ts index 2a922a2..9bd608f 100644 --- a/backend/server/api/users/me/push-token.post.ts +++ b/backend/server/api/users/me/push-token.post.ts @@ -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: { diff --git a/backend/server/services/push.ts b/backend/server/services/push.ts index 19a94b1..0d8a911 100644 --- a/backend/server/services/push.ts +++ b/backend/server/services/push.ts @@ -169,6 +169,11 @@ export async function sendCallRingPush(payload: CallRingPushPayload): Promiset.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