diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index 51519a7b..ce119bc7 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -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(); } } diff --git a/android/app/src/main/java/chat/vojo/app/OverLockCallPlugin.java b/android/app/src/main/java/chat/vojo/app/OverLockCallPlugin.java new file mode 100644 index 00000000..8ff60a80 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/OverLockCallPlugin.java @@ -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 Activity→Plugin 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()); + } +} diff --git a/public/locales/en.json b/public/locales/en.json index d8921242..b5919e14 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index 8e59809f..e13a925d 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -560,6 +560,7 @@ "connecting": "Соединение…", "calling": "Вызов…", "open_call_room": "Открыть чат звонка", + "minimize": "Свернуть", "bubble_outgoing": "Исходящий звонок", "bubble_incoming": "Входящий звонок", "bubble_missed": "Пропущенный звонок", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 8d891f4d..88ddbcec 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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} /> + ); } diff --git a/src/app/features/call-status/OverLockCallScreen.tsx b/src/app/features/call-status/OverLockCallScreen.tsx new file mode 100644 index 00000000..0ca6cd4e --- /dev/null +++ b/src/app/features/call-status/OverLockCallScreen.tsx @@ -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 = ( + + + +); + +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 = ( +
+ {/* Second incoming call (arrived while over the lock) — banner on top. */} + {secondCall && secondRoom && ( +
+ +
+ )} + + {/* Top bar: minimize (only meaningful when a call is live). */} +
+ {callEmbed && ( + + )} +
+ + {/* Identity (vertically centred). */} +
+
+ {initial} +
+
+ {name} +
+ + + + {status} + + +
+ + {/* Controls. */} +
+ {callEmbed ? ( + + ) : ( +
+ +
+ )} +
+
+ ); + + return createPortal(screen, document.body); +} diff --git a/src/app/hooks/useOverLockCall.ts b/src/app/hooks/useOverLockCall.ts new file mode 100644 index 00000000..0bd3391f --- /dev/null +++ b/src/app/hooks/useOverLockCall.ts @@ -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]); +}; diff --git a/src/app/plugins/call/overLockCall.ts b/src/app/plugins/call/overLockCall.ts new file mode 100644 index 00000000..c3cfedd0 --- /dev/null +++ b/src/app/plugins/call/overLockCall.ts @@ -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; + ready(): Promise; + exit(): Promise; + addListener( + eventName: 'overLockUnlocked', + listenerFunc: () => void + ): Promise; +} + +const plugin = registerPlugin('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 { + 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 { + 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 { + 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 { + if (!isAndroidPlatform()) { + return Promise.resolve({ remove: () => Promise.resolve() }); + } + return plugin.addListener('overLockUnlocked', listenerFunc); + }, +}; diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index c7feacec..2c15fb79 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -20,7 +20,14 @@ export const callEmbedAtom = atom(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(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(false);