From be2019daebf2452456a857fa929139e823fd4afc Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Thu, 23 Apr 2026 01:27:51 +0300 Subject: [PATCH] Gate native CallStyle dismiss on App.isActive so background incoming rings keep ringing instead of being prematurely cleared by /sync atom ADD. --- .../useDismissNativeCallNotifications.ts | 134 +++++++++++++----- 1 file changed, 101 insertions(+), 33 deletions(-) diff --git a/src/app/hooks/useDismissNativeCallNotifications.ts b/src/app/hooks/useDismissNativeCallNotifications.ts index a075e8ca..b11cf924 100644 --- a/src/app/hooks/useDismissNativeCallNotifications.ts +++ b/src/app/hooks/useDismissNativeCallNotifications.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; +import { App } from '@capacitor/app'; import { incomingCallsAtom } from '../state/incomingCalls'; import { isNativePlatform } from '../utils/capacitor'; @@ -17,51 +18,118 @@ function javaStringHashCode(s: string): number { return h; } -// Keep native CallStyle in sync with the JS-owned incoming-calls atom. When JS -// either loses a ring (accepted, declined, other device joined, lifetime -// expired) OR newly takes ownership of one after the app returns foreground, -// clear the system notification for that room. The AlarmManager fallback in the -// Java service still handles killed-process dismiss on lifetime expiry. +async function dismissRooms(roomIds: string[]): Promise { + if (roomIds.length === 0) return; + if (!isNativePlatform()) return; + const { PushNotifications } = await import('@capacitor/push-notifications'); + // Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification: + // tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor + // plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java + // line 166), so the id must go over the bridge as a number — the TS + // type declares string, hence the cast. + const notifications = roomIds.map((roomId) => { + const tag = `call_${roomId}`; + let id = javaStringHashCode(tag); + if (id === SUMMARY_NOTIFICATION_ID) id += 1; + return { id, tag }; + }); + await PushNotifications.removeDeliveredNotifications({ + notifications: notifications as unknown as Parameters< + typeof PushNotifications.removeDeliveredNotifications + >[0]['notifications'], + }).catch(() => { + /* best-effort — alarm fallback still dismisses at lifetime expiry */ + }); +} + +// Keep native CallStyle in sync with the JS-owned incoming-calls atom, enforcing +// the "foreground → JS strip, background → platform surface" invariant on the +// dismiss side (symmetric to the show-side skip in VojoFirebaseMessagingService +// and the JS-audio gate in IncomingCallStripRenderer). +// - REMOVE (ring ended: accept/decline/other-device/expiry) → dismiss always. +// - ADD while foregrounded → JS strip owns UX → dismiss any stale native. +// - ADD while backgrounded → native CallStyle owns UX → keep it, do NOT dismiss. +// - background → foreground transition with a live ring → one-shot sweep hands +// ownership back to the JS strip. +// The AlarmManager fallback in the Java service still handles killed-process +// dismiss on lifetime expiry. export const useDismissNativeCallNotifications = (): void => { const incoming = useAtomValue(incomingCallsAtom); const prevRoomsRef = useRef>(new Set()); + const [appActive, setAppActive] = useState( + () => typeof document === 'undefined' || document.visibilityState === 'visible' + ); + // App state tracking. Race-guard: a real appStateChange firing before + // getState() resolves must win, otherwise the stale snapshot re-enables the + // foreground branch while we are actually backgrounded (and we'd dismiss + // the native ring we just raised). + useEffect(() => { + let cancelled = false; + let sawAppStateEvent = false; + let remove: (() => void) | undefined; + + App.addListener('appStateChange', ({ isActive }) => { + sawAppStateEvent = true; + if (!cancelled) setAppActive(isActive); + }).then((handle) => { + if (cancelled) handle.remove(); + else + remove = () => { + handle.remove(); + }; + }); + + App.getState() + .then((state) => { + if (!cancelled && !sawAppStateEvent) setAppActive(state.isActive); + }) + .catch(() => { + /* web fallback handled by initial document.visibilityState */ + }); + + return () => { + cancelled = true; + remove?.(); + }; + }, []); + + // Diff-driven dismiss. prevRoomsRef is the write-site for the current-rooms + // snapshot; the handoff-effect below reads it and relies on this effect + // running first on shared re-renders (React runs effects in declaration + // order — do not reorder). useEffect(() => { const nextRooms = new Set(); incoming.forEach((call) => nextRooms.add(call.roomId)); - const changed: string[] = []; + const added: string[] = []; + const removed: string[] = []; prevRoomsRef.current.forEach((roomId) => { - if (!nextRooms.has(roomId)) changed.push(roomId); + if (!nextRooms.has(roomId)) removed.push(roomId); }); nextRooms.forEach((roomId) => { - if (!prevRoomsRef.current.has(roomId)) changed.push(roomId); + if (!prevRoomsRef.current.has(roomId)) added.push(roomId); }); prevRoomsRef.current = nextRooms; - if (changed.length === 0) return; - if (!isNativePlatform()) return; + const toDismiss = appActive ? [...removed, ...added] : removed; + dismissRooms(toDismiss).catch(() => { + /* best-effort */ + }); + }, [incoming, appActive]); - (async () => { - const { PushNotifications } = await import('@capacitor/push-notifications'); - // Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification: - // tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor - // plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java - // line 166), so the id must go over the bridge as a number — the TS - // type declares string, hence the cast. - const notifications = changed.map((roomId) => { - const tag = `call_${roomId}`; - let id = javaStringHashCode(tag); - if (id === SUMMARY_NOTIFICATION_ID) id += 1; - return { id, tag }; - }); - await PushNotifications.removeDeliveredNotifications({ - notifications: notifications as unknown as Parameters< - typeof PushNotifications.removeDeliveredNotifications - >[0]['notifications'], - }).catch(() => { - /* best-effort — alarm fallback still dismisses at lifetime expiry */ - }); - })(); - }, [incoming]); + // Handoff sweep on background → foreground. Reads prevRoomsRef populated by + // the diff-effect above. Also fires on initial mount when appActive=true + // with a non-empty atom (Answer-from-killed cold boot where /sync populated + // the atom before first render) — JS now owns ring UX so any native + // CallStyle raised by the FCM path must go. Double-dismiss with the + // diff-effect on a simultaneous transition+ADD tick is harmless (native + // cancel is idempotent). + useEffect(() => { + if (!appActive) return; + if (prevRoomsRef.current.size === 0) return; + dismissRooms(Array.from(prevRoomsRef.current)).catch(() => { + /* best-effort */ + }); + }, [appActive]); };