Gate native CallStyle dismiss on App.isActive so background incoming rings keep ringing instead of being prematurely cleared by /sync atom ADD.

This commit is contained in:
heaven 2026-04-23 01:27:51 +03:00
parent 0c30e37b70
commit fb6f7bd982

View file

@ -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<void> {
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<Set<string>>(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<string>();
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]);
};