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:
parent
0c30e37b70
commit
fb6f7bd982
1 changed files with 101 additions and 33 deletions
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue