feat(calls): over-lock call screen — answer without unlocking (element-x pattern), minimize + live timer
This commit is contained in:
parent
fa09674748
commit
3d10fc2d3e
9 changed files with 667 additions and 8 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -560,6 +560,7 @@
|
|||
"connecting": "Соединение…",
|
||||
"calling": "Вызов…",
|
||||
"open_call_room": "Открыть чат звонка",
|
||||
"minimize": "Свернуть",
|
||||
"bubble_outgoing": "Исходящий звонок",
|
||||
"bubble_incoming": "Входящий звонок",
|
||||
"bubble_missed": "Пропущенный звонок",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
255
src/app/features/call-status/OverLockCallScreen.tsx
Normal file
255
src/app/features/call-status/OverLockCallScreen.tsx
Normal 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);
|
||||
}
|
||||
62
src/app/hooks/useOverLockCall.ts
Normal file
62
src/app/hooks/useOverLockCall.ts
Normal 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]);
|
||||
};
|
||||
64
src/app/plugins/call/overLockCall.ts
Normal file
64
src/app/plugins/call/overLockCall.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue