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}
/>
+