Use Capacitor pause/resume events on Android to gate appActive at the same lifecycle edge as MainActivity.isInForeground.
This commit is contained in:
parent
aaebdffc4d
commit
a35dfb1a5b
2 changed files with 65 additions and 35 deletions
|
|
@ -2,7 +2,7 @@ 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';
|
||||
import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor';
|
||||
|
||||
const SUMMARY_NOTIFICATION_ID = -2147483648;
|
||||
|
||||
|
|
@ -60,29 +60,45 @@ export const useDismissNativeCallNotifications = (): void => {
|
|||
() => 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).
|
||||
// App state tracking.
|
||||
// Android: `pause`/`resume` fire inside BridgeActivity.onPause/onResume via
|
||||
// AppPlugin.handleOnPause/Resume — the same Activity callbacks that flip
|
||||
// MainActivity.isInForeground — and reach JS within ms via the WebView
|
||||
// bridge. `appStateChange` on Android fires only in BridgeActivity.onStop,
|
||||
// tens of ms to ~1s after onPause; using it as the gate would leak incoming
|
||||
// rings into a "just backgrounded" window where Java sees background (FCM
|
||||
// shows native CallStyle) but JS still sees active (and the dismiss branch
|
||||
// below would clobber the freshly-raised native ring).
|
||||
// iOS/web keeps `appStateChange` because there it maps to
|
||||
// willResignActive/didBecomeActive (iOS) or document.visibilitychange (web)
|
||||
// — the correct edge. The Capacitor `pause`/`resume` events on iOS bind to
|
||||
// didEnterBackground/willEnterForeground, which is a different semantics.
|
||||
// Race-guard: a real lifecycle event arriving before getState() resolves
|
||||
// must win over the stale snapshot.
|
||||
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();
|
||||
let sawLifecycleEvent = false;
|
||||
const handles: Array<{ remove: () => void }> = [];
|
||||
const track = (h: { remove: () => void }) => {
|
||||
if (cancelled) h.remove();
|
||||
else handles.push(h);
|
||||
};
|
||||
});
|
||||
|
||||
const apply = (isActive: boolean) => {
|
||||
sawLifecycleEvent = true;
|
||||
if (!cancelled) setAppActive(isActive);
|
||||
};
|
||||
|
||||
if (isAndroidPlatform()) {
|
||||
App.addListener('pause', () => apply(false)).then(track);
|
||||
App.addListener('resume', () => apply(true)).then(track);
|
||||
} else {
|
||||
App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track);
|
||||
}
|
||||
|
||||
App.getState()
|
||||
.then((state) => {
|
||||
if (!cancelled && !sawAppStateEvent) setAppActive(state.isActive);
|
||||
if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive);
|
||||
})
|
||||
.catch(() => {
|
||||
/* web fallback handled by initial document.visibilityState */
|
||||
|
|
@ -90,7 +106,7 @@ export const useDismissNativeCallNotifications = (): void => {
|
|||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
remove?.();
|
||||
handles.forEach((h) => h.remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { App } from '@capacitor/app';
|
|||
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { IncomingCallStrip } from '../features/call-status';
|
||||
import { isAndroidPlatform } from '../utils/capacitor';
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import RingSoundOgg from '../../../public/sound/ring.ogg';
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
|
|
@ -44,27 +45,40 @@ export function IncomingCallStripRenderer() {
|
|||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Listener registration and getState() race: if a real appStateChange
|
||||
// fires before getState() resolves, the stale getState() snapshot would
|
||||
// overwrite the fresh event and re-open §5.39 (e.g. background → listener
|
||||
// sets false → late getState() resolves true → audio re-enabled).
|
||||
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();
|
||||
// Android: pause/resume fire on BridgeActivity.onPause/onResume via
|
||||
// AppPlugin.handleOnPause/Resume — same Activity callbacks that flip
|
||||
// MainActivity.isInForeground — reaching JS within ms via the WebView
|
||||
// bridge. appStateChange on Android only fires in onStop (tens of ms to
|
||||
// ~1s after onPause), which would let JS audio race the native ringtone
|
||||
// in the "just backgrounded" window and double-ring.
|
||||
// iOS/web keeps appStateChange because there it maps to
|
||||
// willResignActive/didBecomeActive (iOS) or document.visibilitychange
|
||||
// (web). The Capacitor pause/resume events on iOS bind to
|
||||
// didEnterBackground/willEnterForeground — a different edge.
|
||||
// Race-guard: a real lifecycle event arriving before getState() resolves
|
||||
// must win over the stale snapshot.
|
||||
let sawLifecycleEvent = false;
|
||||
const handles: Array<{ remove: () => void }> = [];
|
||||
const track = (h: { remove: () => void }) => {
|
||||
if (cancelled) h.remove();
|
||||
else handles.push(h);
|
||||
};
|
||||
});
|
||||
|
||||
const apply = (isActive: boolean) => {
|
||||
sawLifecycleEvent = true;
|
||||
if (!cancelled) setAppActive(isActive);
|
||||
};
|
||||
|
||||
if (isAndroidPlatform()) {
|
||||
App.addListener('pause', () => apply(false)).then(track);
|
||||
App.addListener('resume', () => apply(true)).then(track);
|
||||
} else {
|
||||
App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track);
|
||||
}
|
||||
|
||||
App.getState()
|
||||
.then((state) => {
|
||||
if (!cancelled && !sawAppStateEvent) setAppActive(state.isActive);
|
||||
if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive);
|
||||
})
|
||||
.catch(() => {
|
||||
/* web fallback handled by initial document.visibilityState */
|
||||
|
|
@ -72,7 +86,7 @@ export function IncomingCallStripRenderer() {
|
|||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
remove?.();
|
||||
handles.forEach((h) => h.remove());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue