feat(calls): over-lock call screen — answer without unlocking (element-x pattern), minimize + live timer

This commit is contained in:
heaven 2026-06-24 00:43:39 +03:00
parent fa09674748
commit 3d10fc2d3e
9 changed files with 667 additions and 8 deletions

View file

@ -1,10 +1,19 @@
package chat.vojo.app;
import android.app.KeyguardManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import androidx.activity.EdgeToEdge;
import androidx.core.graphics.Insets;
import androidx.core.splashscreen.SplashScreen;
@ -15,6 +24,7 @@ import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final String TAG = "VojoMainActivity";
public static volatile boolean isInForeground = false;
private static volatile boolean launchSplashReady = false;
@ -52,6 +62,29 @@ public class MainActivity extends BridgeActivity {
private final Runnable cancelRunnable = () ->
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
// Over-lock call (answer without unlock): when the Answer intent carries
// over_lock=true on a locked device we make MainActivity showWhenLocked and
// drop an opaque cover over the WebView so no chat flashes over the lock
// until the JS over-lock call screen has painted (OverLockCallPlugin.ready()).
private volatile boolean overLock = false;
private View overLockCover;
private BroadcastReceiver userPresentReceiver;
// Safety net for the opaque over-lock cover: it is normally dropped by JS
// (OverLockCallPlugin.ready/exit) or USER_PRESENT. If JS never signals
// (boot crash, getState failure, or a login redirect where the over-lock
// screen lives in an authenticated subtree that never mounts), the cover
// would otherwise strand the device on a black, touch-swallowing screen over
// the lock. 15s comfortably outlives a cold boot + WebView call mount; past
// it we leave over-lock entirely (clears showWhenLocked + drops the cover)
// the privacy-safe fallback. (M3)
private static final long OVER_LOCK_COVER_SAFETY_MS = 15_000L;
private final Runnable overLockCoverSafety = () -> {
if (overLock) {
Log.w(TAG, "over-lock cover safety timeout — leaving over-lock");
exitOverLock();
}
};
public static void releaseLaunchSplash() {
launchSplashReady = true;
}
@ -67,10 +100,12 @@ public class MainActivity extends BridgeActivity {
// super.onCreate would make the plugin invisible to JS until the next relaunch.
registerPlugin(FullScreenIntentPlugin.class);
registerPlugin(CallForegroundPlugin.class);
registerPlugin(AudioRoutePlugin.class);
// Self-managed Telecom call session (docs/plans/telecom_migration.md).
// Internally no-ops on API < 26; JS gates on TELECOM_ENABLED.
// Self-managed Telecom call session (docs/plans/telecom_migration.md)
// the sole native call backend; owns audio routing (legacy AudioRoute
// plugin retired). Android-only; JS gates on isAndroidPlatform().
registerPlugin(TelecomCallPlugin.class);
// Answer-over-lockscreen (showWhenLocked + opaque cover) bridge.
registerPlugin(OverLockCallPlugin.class);
registerPlugin(LaunchSplashPlugin.class);
registerPlugin(ShareTargetPlugin.class);
registerPlugin(PollingPlugin.class);
@ -150,12 +185,161 @@ public class MainActivity extends BridgeActivity {
return windowInsets;
});
ViewCompat.requestApplyInsets(contentRoot);
// If this launch is an Answer-over-lock, show over the keyguard + cover
// the WebView before it can paint a room over the lock.
handleOverLockIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
// Keep getIntent() current (Capacitor push-action consumers read it) and
// re-evaluate over-lock for an answer that arrived while we were alive.
setIntent(intent);
handleOverLockIntent(intent);
}
// Over-lock call (answer without unlock)
// NB: fires twice on cold start (BridgeActivity.load() onNewIntent, then
// onCreate), so everything below must stay idempotent.
private void handleOverLockIntent(Intent intent) {
if (intent == null || !intent.getBooleanExtra("over_lock", false)) return;
// Consume the extra so a later Activity recreate (e.g. fontScale change,
// not in configChanges) can't re-enter over-lock with no live call.
intent.removeExtra("over_lock");
setIntent(intent);
// Trust the over_lock flag (IncomingCallActivity computed it from the
// keyguard at tap time) rather than re-reading isKeyguardLocked() here:
// during the turnScreenOn handoff the keyguard can transiently report
// unlocked, and a false-negative would skip the cover and flash chats
// over the lock (M4). If the device really is already unlocked, onResume's
// keyguard backstop + USER_PRESENT immediately leave over-lock so an
// over-enter self-corrects, while an under-enter would leak privacy.
enterOverLock();
}
private void enterOverLock() {
if (overLock) return;
overLock = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true);
setTurnScreenOn(true);
} else {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
}
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
addOverLockCover();
registerUserPresent();
// Arm the cover safety net (M3); cancelled when JS readies/exits.
lifecycleHandler.removeCallbacks(overLockCoverSafety);
lifecycleHandler.postDelayed(overLockCoverSafety, OVER_LOCK_COVER_SAFETY_MS);
}
private void addOverLockCover() {
if (overLockCover != null) return;
ViewGroup root = findViewById(android.R.id.content);
if (root == null) return;
View cover = new View(this);
cover.setBackgroundColor(Color.parseColor("#0d0e11")); // DAWN.bg2 / call_bg
cover.setClickable(true); // swallow touches landing on a not-yet-covered route
root.addView(cover, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
overLockCover = cover;
}
/** JS over-lock overlay painted → reveal the WebView (call screen). */
public void dropOverLockCover() {
// JS signalled ready the call screen is up, so the cover safety net is
// no longer needed (M3).
lifecycleHandler.removeCallbacks(overLockCoverSafety);
lifecycleHandler.post(() -> {
if (overLockCover != null && overLockCover.getParent() instanceof ViewGroup) {
((ViewGroup) overLockCover.getParent()).removeView(overLockCover);
}
overLockCover = null;
});
}
public void exitOverLock() {
exitOverLock(null);
}
/**
* Call ended (or user unlocked) leave over-lock; device returns to lock.
* [done] runs AFTER showWhenLocked is cleared the JS side awaits it before
* dropping its opaque overlay, so the WebView is never left bare-and-visible
* over the lock during teardown (privacy).
*/
public void exitOverLock(Runnable done) {
lifecycleHandler.removeCallbacks(overLockCoverSafety);
lifecycleHandler.post(() -> {
if (overLock) {
overLock = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false);
setTurnScreenOn(false);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
}
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if (overLockCover != null && overLockCover.getParent() instanceof ViewGroup) {
((ViewGroup) overLockCover.getParent()).removeView(overLockCover);
}
overLockCover = null;
unregisterUserPresent();
}
if (done != null) done.run();
});
}
private void registerUserPresent() {
if (userPresentReceiver != null) return;
userPresentReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context c, Intent i) {
// Device unlocked leave over-lock; JS drops the call overlay and
// the normal (now authenticated) app shows through.
exitOverLock();
OverLockCallPlugin.notifyUnlocked();
}
};
registerReceiver(userPresentReceiver, new IntentFilter(Intent.ACTION_USER_PRESENT));
}
private void unregisterUserPresent() {
if (userPresentReceiver == null) return;
try {
unregisterReceiver(userPresentReceiver);
} catch (Throwable ignored) {
// already unregistered
}
userPresentReceiver = null;
}
public boolean isOverLock() {
return overLock;
}
public boolean isKeyguardLockedNow() {
KeyguardManager km = getSystemService(KeyguardManager.class);
return km != null && km.isKeyguardLocked();
}
@Override
public void onResume() {
super.onResume();
isInForeground = true;
// Backstop for unlock flows that don't broadcast ACTION_USER_PRESENT
// (some OEM biometric / insecure-keyguard dismissals): if we're over-lock
// but the keyguard is gone, leave over-lock so the overlay drops.
if (overLock && !isKeyguardLockedNow()) {
exitOverLock();
OverLockCallPlugin.notifyUnlocked();
}
// Cancel any pending render: user came back before the debounce fired,
// JS strip will own UX, no need to surface native.
lifecycleHandler.removeCallbacks(renderRunnable);
@ -187,6 +371,7 @@ public class MainActivity extends BridgeActivity {
// can't fire post-destroy and land an nm.notify / nm.cancel against a
// dead Activity context on config change (rotation) or process teardown.
lifecycleHandler.removeCallbacksAndMessages(null);
unregisterUserPresent();
super.onDestroy();
}
}

View file

@ -0,0 +1,79 @@
package chat.vojo.app;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* Bridge for the "answer a call over the lockscreen without unlocking" flow
* (docs/plans/telecom_migration.md). The actual window work lives in
* {@link MainActivity}: when the Answer intent carries {@code over_lock=true} and
* the keyguard is locked, MainActivity makes itself showWhenLocked and drops an
* opaque cover over the WebView so no chat content flashes over the lock until
* the JS over-lock call screen has painted.
*
* This plugin only exposes that state to JS and lets JS:
* - getState() am I currently over the lock?
* - ready() the full-screen call overlay has mounted; drop the cover.
* - exit() the call ended; leave over-lock (the device returns to the
* lockscreen, still locked the user never had to unlock).
* and emits {@code overLockUnlocked} when the user unlocks the device (so JS
* tears the over-lock overlay down and the normal app shows through).
*/
@CapacitorPlugin(name = "OverLockCall")
public class OverLockCallPlugin extends Plugin {
// Singleton so MainActivity's USER_PRESENT receiver can push the unlock event
// to JS without holding an ActivityPlugin reference.
private static volatile OverLockCallPlugin instance;
@Override
public void load() {
instance = this;
}
@Override
protected void handleOnDestroy() {
if (instance == this) instance = null;
}
private MainActivity mainActivity() {
return getActivity() instanceof MainActivity ? (MainActivity) getActivity() : null;
}
@PluginMethod
public void getState(PluginCall call) {
MainActivity a = mainActivity();
JSObject r = new JSObject();
r.put("overLock", a != null && a.isOverLock());
r.put("keyguardLocked", a != null && a.isKeyguardLockedNow());
call.resolve(r);
}
@PluginMethod
public void ready(PluginCall call) {
MainActivity a = mainActivity();
if (a != null) a.dropOverLockCover();
call.resolve();
}
@PluginMethod
public void exit(PluginCall call) {
MainActivity a = mainActivity();
if (a == null) {
call.resolve();
return;
}
// Resolve only AFTER showWhenLocked is cleared, so JS can await this
// before dropping its opaque overlay (no bare WebView over the lock).
a.exitOverLock(() -> call.resolve());
}
/** Called by MainActivity when ACTION_USER_PRESENT fires (device unlocked). */
static void notifyUnlocked() {
OverLockCallPlugin p = instance;
if (p != null) p.notifyListeners("overLockUnlocked", new JSObject());
}
}

View file

@ -556,6 +556,7 @@
"connecting": "Connecting…",
"calling": "Calling…",
"open_call_room": "Open call room",
"minimize": "Minimize",
"bubble_outgoing": "Outgoing call",
"bubble_incoming": "Incoming call",
"bubble_missed": "Missed call",

View file

@ -560,6 +560,7 @@
"connecting": "Соединение…",
"calling": "Вызов…",
"open_call_room": "Открыть чат звонка",
"minimize": "Свернуть",
"bubble_outgoing": "Исходящий звонок",
"bubble_incoming": "Входящий звонок",
"bubble_missed": "Пропущенный звонок",

View file

@ -14,6 +14,8 @@ import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useAndroidCallForegroundSync } from '../hooks/useAndroidCallForegroundSync';
import { useTelecomConnectionSync } from '../hooks/useTelecomConnectionSync';
import { useOverLockCall } from '../hooks/useOverLockCall';
import { OverLockCallScreen } from '../features/call-status/OverLockCallScreen';
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
@ -50,9 +52,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const joined = useCallJoined(callEmbed);
useAndroidCallForegroundSync();
// Self-managed Telecom session (docs/plans/telecom_migration.md). No-op
// unless TELECOM_ENABLED + Android + API>=26; keyed on the same joined signal.
// Self-managed Telecom session (docs/plans/telecom_migration.md). Android-only
// (no-op on web / iOS); keyed on the same joined signal as the FGS sync.
useTelecomConnectionSync();
// Over-lock call screen lifecycle (answer without unlock); Android-only.
useOverLockCall();
const selectedRoom = useSelectedRoom();
const chat = useAtomValue(callChatAtom);
@ -84,6 +88,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
}}
ref={callEmbedRef}
/>
<OverLockCallScreen />
</CallEmbedContextProvider>
);
}

View file

@ -0,0 +1,255 @@
import React, { useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { callEmbedAtom, overLockCallAtom } from '../../state/callEmbed';
import { incomingCallsAtom } from '../../state/incomingCalls';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallDuration, formatCallTimer } from '../../hooks/useCallDuration';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { overLockCall } from '../../plugins/call/overLockCall';
import { DarkTheme } from '../../hooks/useTheme';
import * as css from './styles.css';
import { CallControl } from './CallControl';
import { CallActionButton } from './CallActionButton';
import { IncomingCallStrip } from './IncomingCallStrip';
import { CallPhoneDownIcon } from './callIcons';
// Full-screen call surface shown OVER the lockscreen for a call answered without
// unlocking (Android). It covers the rest of the app for privacy — only the
// caller identity, the live timer and the call controls (mute / speaker /
// hangup, reused from CallControl) are visible. Audio runs in the (hidden)
// Element Call WebView; this is pure UI.
//
// COLOUR. The palette is pinned to the same DAWN dark tokens the native
// IncomingCallActivity hardcodes (res/values/colors.xml) — a call over the lock
// is always dark, like every native dialer, regardless of the user's in-app
// theme. We ALSO scope `DarkTheme.classNames` onto the portal root so the reused
// CallControl's folds tokens resolve dark too: the screen can paint before/around
// ThemeManager's body-class effect (cold-start answer-from-killed), and in light
// theme `color.Surface.*` would otherwise be a white surface with dark text —
// the "black text on a dark cover" bug.
//
// On mount it (a) reveals the native opaque cover after this overlay has actually
// painted (double rAF), and (b) reconciles against native truth — if native has
// already left over-lock (a stale atom set raced an exit), it drops itself.
const DAWN = {
bg: '#0d0e11',
text: '#e6e6e9',
textMuted: '#9aa0a6',
avatarBg: '#9580ff',
avatarText: '#0c0c0e',
};
const ChevronDownIcon = (
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M6 9l6 6 6-6" />
</svg>
);
export function OverLockCallScreen() {
const overLock = useAtomValue(overLockCallAtom);
const setOverLock = useSetAtom(overLockCallAtom);
const callEmbed = useAtomValue(callEmbedAtom);
const joined = useCallJoined(callEmbed);
const incoming = useAtomValue(incomingCallsAtom);
const mx = useMatrixClient();
const { t } = useTranslation();
// Live call timer — starts the moment the widget reports joined.
const duration = useCallDuration(Boolean(callEmbed) && joined);
const timer = duration !== null ? formatCallTimer(duration) : '';
// A SECOND incoming call that arrives while we're over the lock would otherwise
// be invisible: the opaque overlay covers the in-app strip, and the FCM path
// treats the (foreground, over-lock) app as not-backgrounded so it raises no
// CallStyle. Surface it as a banner on top of the over-lock screen, reusing the
// full IncomingCallStrip so its answer/decline/switch logic stays single-source
// (M8). Any ring for a different room than the active call qualifies.
const currentRoomId = callEmbed?.roomId;
const secondCall = useMemo(
() => Array.from(incoming.values()).find((c) => c.roomId !== currentRoomId),
[incoming, currentRoomId]
);
const secondRoom = secondCall ? mx.getRoom(secondCall.roomId) : null;
useEffect(() => {
if (!overLock) return undefined;
// Drop the native cover only after this opaque overlay is on screen.
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => overLockCall.ready());
});
// Follow native truth: if we're not actually over-lock anymore, self-dismiss.
overLockCall.getState().then(
(s) => {
if (!s.overLock) setOverLock(false);
},
() => undefined
);
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [overLock, setOverLock]);
if (!overLock) return null;
const name = callEmbed?.room?.name?.trim() || 'Vojo';
const initial = String.fromCodePoint(name.codePointAt(0) ?? 63).toUpperCase();
const status = callEmbed && joined ? timer || t('Call.in_call') : t('Call.connecting');
// Leave over-lock (device returns to the still-locked lockscreen) WITHOUT
// ending the call. exit() resolves only after native clears showWhenLocked, so
// we drop the opaque overlay strictly after (privacy ordering). Used by the
// minimize affordance (call keeps running, reachable via the ongoing-call
// notification) and by the no-embed bail-out.
const leaveOverLock = () => {
overLockCall.exit().then(
() => setOverLock(false),
() => setOverLock(false)
);
};
const screen = (
<div
className={classNames(...DarkTheme.classNames)}
style={{
position: 'fixed',
inset: 0,
zIndex: 2147483646,
background: DAWN.bg,
color: DAWN.text,
display: 'flex',
flexDirection: 'column',
// Safe-area aware: keep the minimize chevron clear of the status bar and
// the controls clear of the gesture bar.
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 12px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 7vh)',
}}
>
{/* Second incoming call (arrived while over the lock) — banner on top. */}
{secondCall && secondRoom && (
<div style={{ paddingInline: 8, paddingTop: 4 }}>
<IncomingCallStrip call={secondCall} room={secondRoom} />
</div>
)}
{/* Top bar: minimize (only meaningful when a call is live). */}
<div style={{ height: 48, display: 'flex', alignItems: 'center', paddingInline: 12 }}>
{callEmbed && (
<button
type="button"
onClick={leaveOverLock}
aria-label={t('Call.minimize')}
title={t('Call.minimize')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
borderRadius: '50%',
border: 'none',
background: 'transparent',
color: DAWN.text,
cursor: 'pointer',
}}
>
{ChevronDownIcon}
</button>
)}
</div>
{/* Identity (vertically centred). */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 18,
minHeight: 0,
}}
>
<div
className={classNames(!joined && css.CallerPulse)}
style={{
width: 124,
height: 124,
borderRadius: '50%',
background: DAWN.avatarBg,
color: DAWN.avatarText,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 52,
fontWeight: 700,
userSelect: 'none',
}}
>
{initial}
</div>
<div
style={{
color: DAWN.text,
fontSize: 27,
fontWeight: 700,
textAlign: 'center',
maxWidth: '80vw',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name}
</div>
<span className={css.StateLine}>
<span
className={classNames(css.LiveDot, callEmbed && joined && css.LiveDotPulsing)}
aria-hidden
/>
<span
className={css.Timer}
style={{ color: DAWN.textMuted, fontSize: 16, fontWeight: 500 }}
>
{status}
</span>
</span>
</div>
{/* Controls. */}
<div style={{ paddingInline: 16 }}>
{callEmbed ? (
<CallControl callEmbed={callEmbed} compact={false} callJoined={joined} native />
) : (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<CallActionButton
icon={CallPhoneDownIcon}
label={t('Call.ctl_end')}
ariaLabel={t('Call.end_call')}
tone="danger"
compact={false}
onClick={leaveOverLock}
/>
</div>
)}
</div>
</div>
);
return createPortal(screen, document.body);
}

View file

@ -0,0 +1,62 @@
// Lifecycle for the over-lock call screen (answer a call without unlocking).
//
// "Enter" over-lock is triggered from the answer path (usePendingCallActionConsumer
// / IncomingCallStrip): right after answering on a locked device, overLockCallAtom
// is set. This hook owns the two "exit" triggers:
// - the user unlocks the device → native fires `overLockUnlocked` → drop the
// overlay so the normal (now authenticated) app shows through;
// - the call ends (callEmbed goes non-null → null) → leave over-lock natively
// (clears showWhenLocked) and drop the atom.
//
// Android-only; a no-op everywhere else.
import { useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import type { PluginListenerHandle } from '@capacitor/core';
import { callEmbedAtom, overLockCallAtom } from '../state/callEmbed';
import { overLockCall } from '../plugins/call/overLockCall';
import { isAndroidPlatform } from '../utils/capacitor';
export const useOverLockCall = (): void => {
const callEmbed = useAtomValue(callEmbedAtom);
const setOverLock = useSetAtom(overLockCallAtom);
const hadEmbedRef = useRef(false);
// Unlock → leave over-lock.
useEffect(() => {
if (!isAndroidPlatform()) return undefined;
let cancelled = false;
let handle: PluginListenerHandle | undefined;
overLockCall
.addListener(() => setOverLock(false))
.then((h) => {
if (cancelled) h.remove();
else handle = h;
})
.catch(() => undefined);
return () => {
cancelled = true;
handle?.remove();
};
}, [setOverLock]);
// Call ended (embed went non-null → null) → exit over-lock. Guard on a prior
// embed so the brief "answered, embed not created yet" window doesn't drop the
// over-lock screen before the call is even live. Order matters for privacy:
// exit() first (it resolves only after native clears showWhenLocked), THEN drop
// the opaque overlay — never leave the WebView bare-and-visible over the lock.
useEffect(() => {
if (!isAndroidPlatform()) return;
if (callEmbed) {
hadEmbedRef.current = true;
return;
}
if (hadEmbedRef.current) {
hadEmbedRef.current = false;
overLockCall.exit().then(
() => setOverLock(false),
() => setOverLock(false)
);
}
}, [callEmbed, setOverLock]);
};

View file

@ -0,0 +1,64 @@
// Typed wrapper around the OverLockCall Capacitor plugin (OverLockCallPlugin.java).
//
// Drives the "answer a call over the lockscreen without unlocking" flow
// (docs/plans/telecom_migration.md). The native side makes MainActivity
// showWhenLocked and drops an opaque cover over the WebView; this layer lets the
// web learn it's over the lock, drop that cover once the call screen has
// painted, leave over-lock when the call ends, and hear when the user unlocks.
//
// Android-only — no-op on web / iOS.
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { isAndroidPlatform } from '../../utils/capacitor';
export interface OverLockState {
// True while MainActivity is showing over the keyguard for an answered call.
overLock: boolean;
// Whether the device keyguard is currently locked.
keyguardLocked: boolean;
}
interface OverLockCallPlugin {
getState(): Promise<OverLockState>;
ready(): Promise<void>;
exit(): Promise<void>;
addListener(
eventName: 'overLockUnlocked',
listenerFunc: () => void
): Promise<PluginListenerHandle>;
}
const plugin = registerPlugin<OverLockCallPlugin>('OverLockCall');
const OFF: OverLockState = { overLock: false, keyguardLocked: false };
export const overLockCall = {
// Are we currently shown over the lock? Called right after answering so the
// web can enter the full-screen over-lock call screen.
async getState(): Promise<OverLockState> {
if (!isAndroidPlatform()) return OFF;
try {
return await plugin.getState();
} catch {
return OFF;
}
},
// The over-lock call screen has mounted → drop the native cover (reveal it).
ready(): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.ready().catch(() => undefined);
},
// Call ended → leave over-lock; the device returns to the (still-locked) lock.
exit(): Promise<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.exit().catch(() => undefined);
},
// Fires when the user unlocks the device → the over-lock overlay should drop
// and the normal (now authenticated) app shows through.
addListener(listenerFunc: () => void): Promise<PluginListenerHandle> {
if (!isAndroidPlatform()) {
return Promise.resolve({ remove: () => Promise.resolve() });
}
return plugin.addListener('overLockUnlocked', listenerFunc);
},
};

View file

@ -20,7 +20,14 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
export const callChatAtom = atom<boolean>(false);
// In-call loudspeaker state (true = громкая связь / speaker, false = earpiece).
// Android-only — driven by `useCallSpeaker` via the native AudioRoute plugin;
// reset to earpiece on every call teardown. Default false because a 1:1 voice
// call should start on the earpiece, like a phone call.
// Android-only — driven by `useCallSpeaker` via Telecom endpoints (the active
// endpoint is mirrored back here by useTelecomConnectionSync); reset to earpiece
// on every call teardown. Default false because a 1:1 voice call should start on
// the earpiece, like a phone call.
export const callSpeakerAtom = atom<boolean>(false);
// True while an answered call is shown OVER the lockscreen (answer-without-unlock,
// Android only). Drives the full-screen over-lock call screen, which covers the
// rest of the app for privacy until the user unlocks. Set when answering on a
// locked device; cleared on unlock or call end. See useOverLockCall / overLockCall.
export const overLockCallAtom = atom<boolean>(false);