From 71a71fa56bb9ed3b27543c870f217d465b0a363a Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 4 Jul 2026 02:14:08 +0300 Subject: [PATCH] feat(calls): verify Answer-intent token + harden native ring lifecycle Every Answer PendingIntent now carries a per-install secret (CallActionToken, private prefs); the JS consumer and MainActivity over-lock verify it before auto-joining, blocking a forged hot-mic join from another local app. MainActivity marks the Telecom session answered natively on a shade Answer, and its over-lock onResume backstop waits a settle window. Native ring start runs fully under telecomLock (no orphan slot), a Telecom failure degrades to notification-only instead of killing the ring, the full-screen ring timeout tracks the real lifetime, and IncomingRing.data is a ConcurrentHashMap. FGS notification/channel strings localized via PushStrings; dead Call.accept key dropped. From the internal multi-agent review. --- .../java/chat/vojo/app/CallActionToken.java | 75 +++++++++++ .../chat/vojo/app/CallForegroundPlugin.java | 14 +++ .../chat/vojo/app/CallForegroundService.java | 8 +- .../chat/vojo/app/IncomingCallActivity.java | 33 +++-- .../main/java/chat/vojo/app/MainActivity.java | 63 +++++++++- .../main/java/chat/vojo/app/PushStrings.java | 12 ++ .../app/VojoFirebaseMessagingService.java | 116 ++++++++++++------ .../app/src/main/res/values-ru/strings.xml | 3 + android/app/src/main/res/values/strings.xml | 4 + public/locales/en.json | 1 - public/locales/ru.json | 1 - src/app/hooks/useAndroidCallForegroundSync.ts | 13 +- src/app/hooks/usePushNotifications.ts | 34 ++++- src/app/plugins/call/callForegroundService.ts | 10 ++ 14 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/CallActionToken.java diff --git a/android/app/src/main/java/chat/vojo/app/CallActionToken.java b/android/app/src/main/java/chat/vojo/app/CallActionToken.java new file mode 100644 index 00000000..76a951fd --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/CallActionToken.java @@ -0,0 +1,75 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; + +/** + * Per-install secret proving that a call Answer intent came from one of OUR own + * PendingIntents rather than a forged launch by another local app. + * + * WHY. MainActivity is an exported launcher activity, and Capacitor's + * PushNotificationsPlugin fires {@code pushNotificationActionPerformed} for ANY + * launch intent that carries a {@code google.message_id} key, without checking + * provenance. Without a shared secret a co-resident app could + * {@code startActivity(MainActivity, extras{google.message_id, room_id, + * call_action:"answer"})} and make the victim auto-join a call with a hot mic — + * a classic intent-redirection to an exported component. + * + * DESIGN. Every Answer PendingIntent we build (CallStyle shade action, + * IncomingCallActivity answer, native remote-answer boot) stamps this token as + * an extra. The JS answer consumer (usePushNotifications) and MainActivity's + * over-lock entry verify it before acting. The token lives in a PRIVATE + * SharedPreferences file (MODE_PRIVATE), unreadable by other apps on a + * non-compromised device, and persists across process death — which the + * answer-from-killed flow requires (the PendingIntent was minted by an earlier + * process instance). A forged intent cannot know it, so verification fails. + */ +final class CallActionToken { + + /** Intent-extra key carrying the token. */ + static final String EXTRA = "vojo_action_token"; + + private static final String PREFS = "vojo_call_secure"; + private static final String KEY = "action_token"; + + private static volatile String cached; + + private CallActionToken() {} + + /** The persistent token, generated + stored on first use. */ + static String get(Context ctx) { + String c = cached; + if (c != null) return c; + synchronized (CallActionToken.class) { + if (cached != null) return cached; + SharedPreferences prefs = ctx.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + String t = prefs.getString(KEY, null); + if (t == null || t.isEmpty()) { + t = generate(); + prefs.edit().putString(KEY, t).apply(); + } + cached = t; + return t; + } + } + + /** Constant-time check that {@code candidate} equals the stored token. */ + static boolean matches(Context ctx, String candidate) { + if (candidate == null || candidate.isEmpty()) return false; + byte[] real = get(ctx).getBytes(StandardCharsets.UTF_8); + byte[] given = candidate.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(real, given); + } + + private static String generate() { + byte[] b = new byte[24]; + new SecureRandom().nextBytes(b); + return Base64.encodeToString(b, Base64.NO_WRAP | Base64.URL_SAFE); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java index e2456cbd..d3abfd5a 100644 --- a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java @@ -9,6 +9,7 @@ import android.util.Log; import androidx.core.content.ContextCompat; +import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; import com.getcapacitor.PluginMethod; @@ -159,4 +160,17 @@ public class CallForegroundPlugin extends Plugin { VojoFirebaseMessagingService.clearAllIncomingRings(getContext()); call.resolve(); } + + // Verify an Answer intent's provenance token (F15). The JS answer consumer + // (usePushNotifications) calls this before honoring a call_action=answer so a + // forged launch by another local app — which cannot produce our per-install + // secret — can't make the device auto-join a call with a hot mic. Returns + // { valid: false } for a missing / mismatched token. + @PluginMethod + public void verifyActionToken(PluginCall call) { + String token = call.getString("token"); + JSObject ret = new JSObject(); + ret.put("valid", CallActionToken.matches(getContext(), token)); + call.resolve(ret); + } } diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java index 03e4c928..86895e44 100644 --- a/android/app/src/main/java/chat/vojo/app/CallForegroundService.java +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundService.java @@ -91,7 +91,7 @@ public class CallForegroundService extends Service { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (nm != null) ensureOngoingChannel(nm); try { - Notification placeholder = buildNotification("Активный звонок", ""); + Notification placeholder = buildNotification(PushStrings.callOngoingTitle(this), ""); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { startForeground(NOTIFICATION_ID, placeholder, ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL); @@ -109,7 +109,7 @@ public class CallForegroundService extends Service { public int onStartCommand(Intent intent, int flags, int startId) { String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null; String body = intent != null ? intent.getStringExtra(EXTRA_BODY) : null; - if (title == null || title.isEmpty()) title = "Активный звонок"; + if (title == null || title.isEmpty()) title = PushStrings.callOngoingTitle(this); if (body == null) body = ""; NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); @@ -222,10 +222,10 @@ public class CallForegroundService extends Service { if (nm.getNotificationChannel(CHANNEL_ID) != null) return; NotificationChannel channel = new NotificationChannel( CHANNEL_ID, - "Активные звонки", + PushStrings.callOngoingChannelName(this), NotificationManager.IMPORTANCE_LOW ); - channel.setDescription("Уведомление во время активного звонка Vojo"); + channel.setDescription(PushStrings.callOngoingChannelDescription(this)); channel.setShowBadge(false); channel.enableLights(false); channel.enableVibration(false); diff --git a/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java index 69b87961..bf2be3e7 100644 --- a/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java +++ b/android/app/src/main/java/chat/vojo/app/IncomingCallActivity.java @@ -40,8 +40,8 @@ import android.widget.TextView; * * LIFECYCLE. Self-finishes when the ring is no longer live: the FCM service calls * {@link #finishFor(String)} from removeIncomingRing (answer / decline / expiry / - * supersede). A 60s safety timeout backstops a missed signal. Back press just - * dismisses the screen; the ring lives on as the CallStyle notification. + * supersede). A lifetime-based safety timeout backstops a missed signal. Back + * press just dismisses the screen; the ring lives on as the CallStyle notification. * * ANSWER. Hands off to MainActivity (the WebView call surface) carrying * over_lock = "device was locked". On a locked device we do NOT force an unlock: @@ -52,10 +52,16 @@ public class IncomingCallActivity extends Activity { private static final String TAG = "VojoIncomingCall"; // Coarse backstop only: the ring's expiry alarm (CallCancelReceiver → - // removeIncomingRing → finishFor) is the real teardown. 60s comfortably - // outlives the ~30s ring so we never finish a still-live ring early; on a - // missed finishFor the screen/wakelock linger at most ~30s past ring death. - private static final long RING_SAFETY_TIMEOUT_MS = 60_000L; + // removeIncomingRing → finishFor) is the real teardown. The window tracks the + // ring's actual lifetime (passed as content_lifetime) + a grace, so a ring + // that legitimately carries a longer-than-30s lifetime isn't finished early + // (F24). Bounded by MAX so a crafted lifetime can't pin the screen/wakelock. + private static final long RING_SAFETY_GRACE_MS = 5_000L; + private static final long RING_SAFETY_DEFAULT_MS = 60_000L; + private static final long RING_SAFETY_MAX_MS = 5 * 60 * 1000L + RING_SAFETY_GRACE_MS; + + // Resolved per ring from the content_lifetime extra in bindFromIntent. + private long ringSafetyTimeoutMs = RING_SAFETY_DEFAULT_MS; // The single visible incoming screen (DM rings are one-at-a-time). Used by // finishFor so the FCM service can dismiss it the moment its ring ends. @@ -138,7 +144,7 @@ public class IncomingCallActivity extends Activity { acquireRingWakeLock(); current = this; - handler.postDelayed(safetyFinish, RING_SAFETY_TIMEOUT_MS); + handler.postDelayed(safetyFinish, ringSafetyTimeoutMs); } @Override @@ -155,7 +161,7 @@ public class IncomingCallActivity extends Activity { // (setReferenceCounted(false) → a fresh acquire just re-arms the timeout). acquireRingWakeLock(); handler.removeCallbacks(safetyFinish); - handler.postDelayed(safetyFinish, RING_SAFETY_TIMEOUT_MS); + handler.postDelayed(safetyFinish, ringSafetyTimeoutMs); } // Returns false (and binds nothing usable) when the launch lacks a room id. @@ -167,6 +173,10 @@ public class IncomingCallActivity extends Activity { Log.w(TAG, "missing room_id, finishing"); return false; } + long lifetime = intent.getLongExtra("content_lifetime", 0L); + ringSafetyTimeoutMs = lifetime > 0 + ? Math.min(lifetime + RING_SAFETY_GRACE_MS, RING_SAFETY_MAX_MS) + : RING_SAFETY_DEFAULT_MS; String callerName = intent.getStringExtra("caller_name"); if (callerName == null || callerName.trim().isEmpty()) { // No caller name in the ring payload — name the state, not the app @@ -219,7 +229,10 @@ public class IncomingCallActivity extends Activity { .putExtra("room_id", roomId) .putExtra("notif_event_id", notifEventId) .putExtra("call_action", "answer") - .putExtra("over_lock", locked); + .putExtra("over_lock", locked) + // Trusted-launch marker: the JS answer consumer + MainActivity over-lock + // reject an answer intent that doesn't carry our secret token (F15). + .putExtra(CallActionToken.EXTRA, CallActionToken.get(getApplicationContext())); try { startActivity(answer); } catch (Throwable t) { @@ -530,7 +543,7 @@ public class IncomingCallActivity extends Activity { wakeLock = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, "chat.vojo.app:IncomingCallWakeLock"); wakeLock.setReferenceCounted(false); - wakeLock.acquire(RING_SAFETY_TIMEOUT_MS); + wakeLock.acquire(ringSafetyTimeoutMs); } catch (Throwable t) { Log.w(TAG, "wakelock acquire failed", t); } 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 3c943615..fbfb5077 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -10,6 +10,7 @@ import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.util.Log; import android.view.View; import android.view.ViewGroup; @@ -69,6 +70,14 @@ public class MainActivity extends BridgeActivity { private volatile boolean overLock = false; private View overLockCover; private BroadcastReceiver userPresentReceiver; + // elapsedRealtime when we entered over-lock. The onResume keyguard backstop + // (which handles OEM/biometric unlocks that don't broadcast USER_PRESENT) + // ignores an "unlocked" reading for a short settle window after entry: during + // the turnScreenOn handoff the keyguard can transiently report unlocked, and + // acting on that false-negative would drop the call surface right as it opens — + // the same transient M4 taught handleOverLockIntent to distrust (F17). + private volatile long overLockEnteredAt = 0L; + private static final long OVER_LOCK_RESUME_SETTLE_MS = 1500L; // 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 @@ -187,8 +196,10 @@ public class MainActivity extends BridgeActivity { }); 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. + // Mark the Telecom session answered natively for a shade Answer that boots + // us (M9/F22), then, if this is an Answer-over-lock, show over the keyguard + // + cover the WebView before it can paint a room over the lock. + handleAnswerIntent(getIntent()); handleOverLockIntent(getIntent()); } @@ -196,11 +207,41 @@ public class MainActivity extends BridgeActivity { 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. + // re-evaluate the answer + over-lock for an answer that arrived while alive. setIntent(intent); + handleAnswerIntent(intent); handleOverLockIntent(intent); } + // A trusted call intent is one carrying our per-install secret token — proof it + // originated from one of our own Answer PendingIntents and not a forged launch + // by another local app (MainActivity is exported). See CallActionToken (F15). + private boolean isTrustedCallIntent(Intent intent) { + return intent != null + && CallActionToken.matches(this, intent.getStringExtra(CallActionToken.EXTRA)); + } + + // Mark the Telecom session answered NATIVELY the instant a CallStyle-shade + // Answer boots MainActivity. The JS consumer only answers seconds later (after + // the WebView loads); in that cold-answer window an expiry alarm / remote + // disconnect would see !isAnswered() and tear down the just-accepted call + // (M9/F22). The full-screen IncomingCallActivity already does this on its own + // answer path — this closes the shade path that goes straight here. Trusted + // intents only (F15). answerCall is room-guarded + idempotent, so the later JS + // answer is a harmless no-op. + private void handleAnswerIntent(Intent intent) { + if (intent == null) return; + if (!"answer".equals(intent.getStringExtra("call_action"))) return; + String roomId = intent.getStringExtra("room_id"); + if (roomId == null || roomId.isEmpty()) return; + if (!isTrustedCallIntent(intent)) return; + try { + VojoCallsManager.getInstance(getApplicationContext()).answerCall(roomId, false); + } catch (Throwable t) { + Log.w(TAG, "handleAnswerIntent: native answerCall threw", t); + } + } + // ── Over-lock call (answer without unlock) ── // NB: fires twice on cold start (BridgeActivity.load() → onNewIntent, then @@ -211,6 +252,11 @@ public class MainActivity extends BridgeActivity { // not in configChanges) can't re-enter over-lock with no live call. intent.removeExtra("over_lock"); setIntent(intent); + // Only a trusted Answer (our secret token) may raise the over-lock surface. + // A forged over_lock=true launch would otherwise flash the opaque cover for + // the safety window — a cosmetic DoS (F15). The dangerous part (auto-join) + // is already blocked by the JS token check; this closes the cover too. + if (!isTrustedCallIntent(intent)) return; // 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 @@ -224,6 +270,7 @@ public class MainActivity extends BridgeActivity { private void enterOverLock() { if (overLock) return; overLock = true; + overLockEnteredAt = SystemClock.elapsedRealtime(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true); setTurnScreenOn(true); @@ -336,8 +383,14 @@ public class MainActivity extends BridgeActivity { 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()) { + // but the keyguard is gone, leave over-lock so the overlay drops. Ignore an + // "unlocked" reading within the settle window after entry — during the + // turnScreenOn handoff the keyguard transiently reports unlocked, and acting + // on it here would drop the call surface right as it opens (F17); USER_PRESENT + // still handles a genuine unlock in that window. + if (overLock + && SystemClock.elapsedRealtime() - overLockEnteredAt > OVER_LOCK_RESUME_SETTLE_MS + && !isKeyguardLockedNow()) { exitOverLock(); OverLockCallPlugin.notifyUnlocked(); } diff --git a/android/app/src/main/java/chat/vojo/app/PushStrings.java b/android/app/src/main/java/chat/vojo/app/PushStrings.java index 56835c5d..4836739b 100644 --- a/android/app/src/main/java/chat/vojo/app/PushStrings.java +++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java @@ -59,6 +59,18 @@ final class PushStrings { return forAppLocale(ctx).getString(R.string.push_missed_call); } + static String callOngoingTitle(Context ctx) { + return forAppLocale(ctx).getString(R.string.call_ongoing_title); + } + + static String callOngoingChannelName(Context ctx) { + return forAppLocale(ctx).getString(R.string.call_ongoing_channel_name); + } + + static String callOngoingChannelDescription(Context ctx) { + return forAppLocale(ctx).getString(R.string.call_ongoing_channel_description); + } + static String missedCallBody(Context ctx, String caller) { String safeCaller = caller == null ? "" : caller; return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller); diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index ade45b72..1d273d03 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -290,7 +290,13 @@ public class VojoFirebaseMessagingService extends MessagingService { volatile long lastAlertedAt; IncomingRing(Map data, String messageId, long seededAt) { - this.data = data; + // Copy into a thread-safe map: merge writes happen under registryLock, + // but renderOne / postIncomingCallNotification read `data` OUTSIDE the + // lock. A plain HashMap under concurrent read+write can corrupt its + // table or spin on resize; ConcurrentHashMap makes each get/put atomic. + // Callers only ever store non-null String values (the merge guards + // e.getValue() != null), so CHM's null-hostility is not a problem (F25). + this.data = new java.util.concurrent.ConcurrentHashMap<>(data); this.messageId = messageId; this.seededAt = seededAt; this.renderedAt = 0L; @@ -1821,41 +1827,53 @@ public class VojoFirebaseMessagingService extends MessagingService { parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS)) + RTC_LIFETIME_GRACE_MS; new Handler(Looper.getMainLooper()).post(() -> { - // Re-check the slot: a decline/expiry/supersede could have run - // maybeTeardownTelecomRing in the post window (clearing the slot). - // Creating the session now would orphan it and wedge the single-session - // slot forever, so abort instead. + // Re-check AND start the whole session UNDER telecomLock (F14 / ORANGE-7). + // The previous fix only re-checked the slot under the lock, then released + // it before startForegroundService + startCall + acquireRingCeiling. A + // concurrent maybeTeardownTelecomRing (JS bridge / same-room eviction on + // another thread) could interleave right after the re-check: it cleared + // telecomRingEventId and stopped the FGS while we went on to create a + // Telecom session with no owner — orphaning it and wedging the single + // call slot (isBusy()==true) forever. Holding the lock across the start + // serializes the two: a teardown either runs fully before (we then abort + // on the re-check) or blocks until we finish (then tears our session down + // cleanly). startCall is bounded under the lock: the addCall coroutine is + // just launched (its CallControlScope block runs later, off this lock), + // and the only synchronous work is a one-time registerAppWithTelecom + // Binder call on the FIRST ring (subsequent calls short-circuit on + // `registered`) plus startForegroundService (a fast schedule) — all on the + // main thread but bounded to quick Binder hops. acquireRingCeiling + // re-enters telecomLock harmlessly (re-entrant); VojoCallsManager never + // calls back into telecomLock, so there is no lock-order cycle. synchronized (telecomLock) { if (!eventId.equals(telecomRingEventId)) { dlog("telecom: ring removed before startCall, aborting event=" + eventId); return; } - } - // phoneCall-only FGS — process retention + answer-from-killed pre-arm. - try { - Intent fgs = new Intent(appCtx, CallForegroundService.class) - .putExtra(CallForegroundService.EXTRA_TITLE, callerName) - .putExtra(CallForegroundService.EXTRA_TYPE_PHONE_CALL, true) - .putExtra(CallForegroundService.EXTRA_TYPE_MICROPHONE, false); - appCtx.startForegroundService(fgs); - } catch (Throwable t) { - Log.w(TAG, "telecom: phoneCall FGS start failed", t); - } - try { - fmgr.setFallbackListener(nativeRingListener(appCtx, eventId, roomId, messageId)); - fmgr.startCall(roomId, callerName, false /* video */, true /* incoming */); - dlog("telecom: incoming session started event=" + eventId); - // Doze-proof in-process ceiling: hold a bounded wakelock + post a - // teardown so this un-answered ring can't outlive its lifetime even - // under deep Doze (RED-1). Cancelled on answer/decline/supersede. - acquireRingCeiling(appCtx, eventId, ringCeilingMs); - } catch (Throwable t) { - Log.w(TAG, "telecom: startCall(incoming) failed", t); - synchronized (telecomLock) { + // phoneCall-only FGS — process retention + answer-from-killed pre-arm. + try { + Intent fgs = new Intent(appCtx, CallForegroundService.class) + .putExtra(CallForegroundService.EXTRA_TITLE, callerName) + .putExtra(CallForegroundService.EXTRA_TYPE_PHONE_CALL, true) + .putExtra(CallForegroundService.EXTRA_TYPE_MICROPHONE, false); + appCtx.startForegroundService(fgs); + } catch (Throwable t) { + Log.w(TAG, "telecom: phoneCall FGS start failed", t); + } + try { + fmgr.setFallbackListener(nativeRingListener(appCtx, eventId, roomId, messageId)); + fmgr.startCall(roomId, callerName, false /* video */, true /* incoming */); + dlog("telecom: incoming session started event=" + eventId); + // Doze-proof in-process ceiling: hold a bounded wakelock + post a + // teardown so this un-answered ring can't outlive its lifetime even + // under deep Doze (RED-1). Cancelled on answer/decline/supersede. + acquireRingCeiling(appCtx, eventId, ringCeilingMs); + } catch (Throwable t) { + Log.w(TAG, "telecom: startCall(incoming) failed", t); if (eventId.equals(telecomRingEventId)) telecomRingEventId = null; releaseRingCeilingLocked(); + stopRingForegroundService(appCtx); } - stopRingForegroundService(appCtx); } }); } @@ -2032,8 +2050,25 @@ public class VojoFirebaseMessagingService extends MessagingService { @Override public void onError(String message) { - Log.w(TAG, "telecom: native ring error: " + message); - removeIncomingRing(appCtx, eventId); + // The Telecom path is unavailable for THIS ring (register / addCall + // failed — OEM quirk, a competing emergency call). Do NOT remove the + // whole ring: the visible CallStyle still works because Answer / + // Decline fire through PendingIntents, not Telecom. Degrade to + // notification-only — release just the slot + ceiling + phoneCall FGS + // this ring owned, and let the CallStyle live to normal expiry (F11). + Log.w(TAG, "telecom: native ring error, degrading to notification-only: " + message); + synchronized (telecomLock) { + if (eventId.equals(telecomRingEventId)) { + telecomRingEventId = null; + releaseRingCeilingLocked(); + } + } + try { + VojoCallsManager.getInstance(appCtx).setFallbackListener(null); + } catch (Throwable t) { + Log.w(TAG, "telecom: clearing fallback after error threw", t); + } + stopRingForegroundService(appCtx); } @Override public void onSetActive() { } @@ -2114,9 +2149,13 @@ public class VojoFirebaseMessagingService extends MessagingService { ctx, declineReq, roomId, notifEventId, tag, notifId ); // Body-tap + full-screen intent → the native full-screen IncomingCallActivity - // over the lockscreen (Telecom is the sole call backend now). - PendingIntent launchPI = - buildIncomingActivityPI(ctx, launchReq, roomId, notifEventId, messageId, callerName); + // over the lockscreen (Telecom is the sole call backend now). Pass the + // clamped ring lifetime so the Activity's self-finish safety timeout tracks + // the real ring window instead of a fixed 60s (F24). + long ringLifetimeMs = clampLifetime( + parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS)); + PendingIntent launchPI = buildIncomingActivityPI( + ctx, launchReq, roomId, notifEventId, messageId, callerName, ringLifetimeMs); Person caller = new Person.Builder().setName(callerName).build(); @@ -2216,7 +2255,12 @@ public class VojoFirebaseMessagingService extends MessagingService { // full-screen IncomingCallActivity: request over-lock so a locked device // answers without a forced unlock. MainActivity re-checks the keyguard, // so this is a no-op when the device is already unlocked. - if ("answer".equals(callAction)) intent.putExtra("over_lock", true); + if ("answer".equals(callAction)) { + intent.putExtra("over_lock", true); + // Prove this Answer came from our own PendingIntent — the JS consumer + // + MainActivity reject a forged launch that can't produce it (F15). + intent.putExtra(CallActionToken.EXTRA, CallActionToken.get(ctx)); + } int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); return PendingIntent.getActivity(ctx, requestCode, intent, flags); @@ -2231,7 +2275,8 @@ public class VojoFirebaseMessagingService extends MessagingService { String roomId, String notifEventId, String messageId, - String callerName + String callerName, + long ringLifetimeMs ) { Intent intent = new Intent(ctx, IncomingCallActivity.class) .setAction(Intent.ACTION_MAIN) @@ -2240,6 +2285,7 @@ public class VojoFirebaseMessagingService extends MessagingService { intent.putExtra("notif_event_id", notifEventId); intent.putExtra("google.message_id", messageId != null ? messageId : ""); intent.putExtra("caller_name", callerName != null ? callerName : ""); + intent.putExtra("content_lifetime", ringLifetimeMs); int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); return PendingIntent.getActivity(ctx, requestCode, intent, flags); diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 9ca43d51..31e958b1 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -4,4 +4,7 @@ Отклонить Входящий звонок Неизвестный абонент + Активный звонок + Активные звонки + Уведомление во время активного звонка Vojo diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 973066f7..d0cbd6f4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -9,4 +9,8 @@ Decline Incoming call Unknown caller + + Ongoing call + Ongoing calls + Notification shown during an active Vojo call diff --git a/public/locales/en.json b/public/locales/en.json index 25c8efe3..289708b5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -532,7 +532,6 @@ "unavailable": "Calls are unavailable", "incoming": "Incoming call…", "incoming_label": "Incoming call", - "accept": "Accept", "answer": "Answer", "decline": "Decline", "unknown_caller": "Unknown caller", diff --git a/public/locales/ru.json b/public/locales/ru.json index 875811b5..5bf7ac85 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -536,7 +536,6 @@ "unavailable": "Звонки недоступны", "incoming": "Входящий звонок…", "incoming_label": "Входящий звонок", - "accept": "Принять", "answer": "Ответить", "decline": "Отклонить", "unknown_caller": "Неизвестный абонент", diff --git a/src/app/hooks/useAndroidCallForegroundSync.ts b/src/app/hooks/useAndroidCallForegroundSync.ts index ad28f500..3e2911e6 100644 --- a/src/app/hooks/useAndroidCallForegroundSync.ts +++ b/src/app/hooks/useAndroidCallForegroundSync.ts @@ -61,13 +61,12 @@ export const useAndroidCallForegroundSync = (): void => { // Tag the active-call FGS with both types: microphone for §2.2 mic // retention under lock, and phoneCall so it matches the ring-time phoneCall // FGS (clean handoff on answer-from-killed) and stays restartable if the - // process is rebuilt mid-call. - callForegroundService - .start({ title: 'Активный звонок', phoneCall: true }) - .catch((err: unknown) => { - // eslint-disable-next-line no-console - console.warn('[call-fgs] start failed', err); - }); + // process is rebuilt mid-call. No title → native fills the localized + // "Ongoing call" (PushStrings), instead of a hardcoded Russian string (F32). + callForegroundService.start({ phoneCall: true }).catch((err: unknown) => { + // eslint-disable-next-line no-console + console.warn('[call-fgs] start failed', err); + }); return () => { callForegroundService.stop().catch((err: unknown) => { diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index 3ec22ade..9ffe0dd8 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -34,6 +34,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { polling, type RoomMetadataMap, type UserAvatarsMap } from '../plugins/polling'; import { getAccountData, getMDirects } from '../utils/room'; import { AccountDataEvent } from '../../types/matrix/accountData'; +import { callForegroundService } from '../plugins/call/callForegroundService'; const noop = (): void => undefined; @@ -617,6 +618,9 @@ export function usePushNotificationsLifecycle(): void { room_id?: string; call_action?: 'answer' | 'decline'; notif_event_id?: string; + // Per-install secret stamped onto our own Answer PendingIntents; a + // forged launch from another local app can't produce it (F15). + vojo_action_token?: string; // Sygnal flattens nested fields with `_` separator; the Android // FCM service forwards every data entry verbatim into the launch // intent (VojoFirebaseMessagingService.java foreach), so these @@ -643,12 +647,30 @@ export function usePushNotificationsLifecycle(): void { // route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs // it doesn't have in its left-rail, hence the DM path here. if (data.call_action === 'answer' && data.room_id) { - navigate(getDirectRoomPath(data.room_id), { replace: true }); - setPendingCallAction({ - kind: 'answer', - roomId: data.room_id, - notifEventId: data.notif_event_id, - }); + const answerRoomId = data.room_id; + const answerNotifEventId = data.notif_event_id; + const dispatchAnswer = () => { + navigate(getDirectRoomPath(answerRoomId), { replace: true }); + setPendingCallAction({ + kind: 'answer', + roomId: answerRoomId, + notifEventId: answerNotifEventId, + }); + }; + // Verify provenance before auto-joining (F15). Fail CLOSED on a real + // mismatch (a forged launch that can't produce our token) but OPEN on a + // plugin/bridge error: verifyActionToken reads local prefs and only + // errors from an environment issue reachable by our own legit flow — + // an attacker's intent yields a definite mismatch, not an error — so + // failing open there preserves answer-from-killed without a hole. + callForegroundService.verifyActionToken(data.vojo_action_token).then( + (valid) => { + if (valid) dispatchAnswer(); + // eslint-disable-next-line no-console + else console.warn('[call] answer intent rejected: bad action token (F15)'); + }, + () => dispatchAnswer() + ); return; } diff --git a/src/app/plugins/call/callForegroundService.ts b/src/app/plugins/call/callForegroundService.ts index 6bd623d1..557a529b 100644 --- a/src/app/plugins/call/callForegroundService.ts +++ b/src/app/plugins/call/callForegroundService.ts @@ -36,6 +36,7 @@ interface CallForegroundServicePlugin { upsertIncomingRing(options: IncomingRingUpsert): Promise; removeIncomingRing(options: { eventId: string }): Promise; clearAllIncomingRings(): Promise; + verifyActionToken(options: { token?: string }): Promise<{ valid: boolean }>; } const plugin = registerPlugin('CallForegroundService'); @@ -77,4 +78,13 @@ export const callForegroundService = { if (!isAndroidPlatform()) return Promise.resolve(); return plugin.clearAllIncomingRings(); }, + // Verify that a native Answer intent carried our per-install secret token + // before honoring it — a forged launch from another local app can set + // call_action/room_id but can't produce the token, so this blocks an + // unauthorized auto-join with a hot mic (F15). Resolves true off-Android + // (no native launch surface to forge) so shared flows aren't gated there. + verifyActionToken(token: string | undefined): Promise { + if (!isAndroidPlatform()) return Promise.resolve(true); + return plugin.verifyActionToken({ token }).then((r) => r.valid); + }, };