Use Capacitor pause/resume events on Android to gate appActive at the same lifecycle edge as MainActivity.isInForeground.

This commit is contained in:
v.lagerev 2026-04-23 22:00:26 +03:00
parent 9e5fa6be3f
commit 649aea7244
2 changed files with 65 additions and 35 deletions

View file

@ -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;
let sawLifecycleEvent = false;
const handles: Array<{ remove: () => void }> = [];
const track = (h: { remove: () => void }) => {
if (cancelled) h.remove();
else handles.push(h);
};
App.addListener('appStateChange', ({ isActive }) => {
sawAppStateEvent = true;
const apply = (isActive: boolean) => {
sawLifecycleEvent = true;
if (!cancelled) setAppActive(isActive);
}).then((handle) => {
if (cancelled) handle.remove();
else
remove = () => {
handle.remove();
};
});
};
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());
};
}, []);

View file

@ -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;
// 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);
};
App.addListener('appStateChange', ({ isActive }) => {
sawAppStateEvent = true;
const apply = (isActive: boolean) => {
sawLifecycleEvent = true;
if (!cancelled) setAppActive(isActive);
}).then((handle) => {
if (cancelled) handle.remove();
else
remove = () => {
handle.remove();
};
});
};
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());
};
}, []);