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.
This commit is contained in:
heaven 2026-07-04 02:14:08 +03:00
parent 64517a73f7
commit 71a71fa56b
14 changed files with 318 additions and 69 deletions

View file

@ -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);
}
}

View file

@ -9,6 +9,7 @@ import android.util.Log;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin; import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall; import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod; import com.getcapacitor.PluginMethod;
@ -159,4 +160,17 @@ public class CallForegroundPlugin extends Plugin {
VojoFirebaseMessagingService.clearAllIncomingRings(getContext()); VojoFirebaseMessagingService.clearAllIncomingRings(getContext());
call.resolve(); 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);
}
} }

View file

@ -91,7 +91,7 @@ public class CallForegroundService extends Service {
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) ensureOngoingChannel(nm); if (nm != null) ensureOngoingChannel(nm);
try { try {
Notification placeholder = buildNotification("Активный звонок", ""); Notification placeholder = buildNotification(PushStrings.callOngoingTitle(this), "");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
startForeground(NOTIFICATION_ID, placeholder, startForeground(NOTIFICATION_ID, placeholder,
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL); ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL);
@ -109,7 +109,7 @@ public class CallForegroundService extends Service {
public int onStartCommand(Intent intent, int flags, int startId) { public int onStartCommand(Intent intent, int flags, int startId) {
String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null; String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null;
String body = intent != null ? intent.getStringExtra(EXTRA_BODY) : 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 = ""; if (body == null) body = "";
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
@ -222,10 +222,10 @@ public class CallForegroundService extends Service {
if (nm.getNotificationChannel(CHANNEL_ID) != null) return; if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
NotificationChannel channel = new NotificationChannel( NotificationChannel channel = new NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Активные звонки", PushStrings.callOngoingChannelName(this),
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
); );
channel.setDescription("Уведомление во время активного звонка Vojo"); channel.setDescription(PushStrings.callOngoingChannelDescription(this));
channel.setShowBadge(false); channel.setShowBadge(false);
channel.enableLights(false); channel.enableLights(false);
channel.enableVibration(false); channel.enableVibration(false);

View file

@ -40,8 +40,8 @@ import android.widget.TextView;
* *
* LIFECYCLE. Self-finishes when the ring is no longer live: the FCM service calls * LIFECYCLE. Self-finishes when the ring is no longer live: the FCM service calls
* {@link #finishFor(String)} from removeIncomingRing (answer / decline / expiry / * {@link #finishFor(String)} from removeIncomingRing (answer / decline / expiry /
* supersede). A 60s safety timeout backstops a missed signal. Back press just * supersede). A lifetime-based safety timeout backstops a missed signal. Back
* dismisses the screen; the ring lives on as the CallStyle notification. * press just dismisses the screen; the ring lives on as the CallStyle notification.
* *
* ANSWER. Hands off to MainActivity (the WebView call surface) carrying * 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: * 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"; private static final String TAG = "VojoIncomingCall";
// Coarse backstop only: the ring's expiry alarm (CallCancelReceiver // Coarse backstop only: the ring's expiry alarm (CallCancelReceiver
// removeIncomingRing finishFor) is the real teardown. 60s comfortably // removeIncomingRing finishFor) is the real teardown. The window tracks the
// outlives the ~30s ring so we never finish a still-live ring early; on a // ring's actual lifetime (passed as content_lifetime) + a grace, so a ring
// missed finishFor the screen/wakelock linger at most ~30s past ring death. // that legitimately carries a longer-than-30s lifetime isn't finished early
private static final long RING_SAFETY_TIMEOUT_MS = 60_000L; // (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 // 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. // finishFor so the FCM service can dismiss it the moment its ring ends.
@ -138,7 +144,7 @@ public class IncomingCallActivity extends Activity {
acquireRingWakeLock(); acquireRingWakeLock();
current = this; current = this;
handler.postDelayed(safetyFinish, RING_SAFETY_TIMEOUT_MS); handler.postDelayed(safetyFinish, ringSafetyTimeoutMs);
} }
@Override @Override
@ -155,7 +161,7 @@ public class IncomingCallActivity extends Activity {
// (setReferenceCounted(false) a fresh acquire just re-arms the timeout). // (setReferenceCounted(false) a fresh acquire just re-arms the timeout).
acquireRingWakeLock(); acquireRingWakeLock();
handler.removeCallbacks(safetyFinish); 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. // 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"); Log.w(TAG, "missing room_id, finishing");
return false; 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"); String callerName = intent.getStringExtra("caller_name");
if (callerName == null || callerName.trim().isEmpty()) { if (callerName == null || callerName.trim().isEmpty()) {
// No caller name in the ring payload name the state, not the app // 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("room_id", roomId)
.putExtra("notif_event_id", notifEventId) .putExtra("notif_event_id", notifEventId)
.putExtra("call_action", "answer") .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 { try {
startActivity(answer); startActivity(answer);
} catch (Throwable t) { } catch (Throwable t) {
@ -530,7 +543,7 @@ public class IncomingCallActivity extends Activity {
wakeLock = pm.newWakeLock( wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "chat.vojo.app:IncomingCallWakeLock"); PowerManager.PARTIAL_WAKE_LOCK, "chat.vojo.app:IncomingCallWakeLock");
wakeLock.setReferenceCounted(false); wakeLock.setReferenceCounted(false);
wakeLock.acquire(RING_SAFETY_TIMEOUT_MS); wakeLock.acquire(ringSafetyTimeoutMs);
} catch (Throwable t) { } catch (Throwable t) {
Log.w(TAG, "wakelock acquire failed", t); Log.w(TAG, "wakelock acquire failed", t);
} }

View file

@ -10,6 +10,7 @@ import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -69,6 +70,14 @@ public class MainActivity extends BridgeActivity {
private volatile boolean overLock = false; private volatile boolean overLock = false;
private View overLockCover; private View overLockCover;
private BroadcastReceiver userPresentReceiver; 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 // Safety net for the opaque over-lock cover: it is normally dropped by JS
// (OverLockCallPlugin.ready/exit) or USER_PRESENT. If JS never signals // (OverLockCallPlugin.ready/exit) or USER_PRESENT. If JS never signals
// (boot crash, getState failure, or a login redirect where the over-lock // (boot crash, getState failure, or a login redirect where the over-lock
@ -187,8 +196,10 @@ public class MainActivity extends BridgeActivity {
}); });
ViewCompat.requestApplyInsets(contentRoot); ViewCompat.requestApplyInsets(contentRoot);
// If this launch is an Answer-over-lock, show over the keyguard + cover // Mark the Telecom session answered natively for a shade Answer that boots
// the WebView before it can paint a room over the lock. // 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()); handleOverLockIntent(getIntent());
} }
@ -196,11 +207,41 @@ public class MainActivity extends BridgeActivity {
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
super.onNewIntent(intent); super.onNewIntent(intent);
// Keep getIntent() current (Capacitor push-action consumers read it) and // 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); setIntent(intent);
handleAnswerIntent(intent);
handleOverLockIntent(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) // Over-lock call (answer without unlock)
// NB: fires twice on cold start (BridgeActivity.load() onNewIntent, then // 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. // not in configChanges) can't re-enter over-lock with no live call.
intent.removeExtra("over_lock"); intent.removeExtra("over_lock");
setIntent(intent); 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 // Trust the over_lock flag (IncomingCallActivity computed it from the
// keyguard at tap time) rather than re-reading isKeyguardLocked() here: // keyguard at tap time) rather than re-reading isKeyguardLocked() here:
// during the turnScreenOn handoff the keyguard can transiently report // during the turnScreenOn handoff the keyguard can transiently report
@ -224,6 +270,7 @@ public class MainActivity extends BridgeActivity {
private void enterOverLock() { private void enterOverLock() {
if (overLock) return; if (overLock) return;
overLock = true; overLock = true;
overLockEnteredAt = SystemClock.elapsedRealtime();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true); setShowWhenLocked(true);
setTurnScreenOn(true); setTurnScreenOn(true);
@ -336,8 +383,14 @@ public class MainActivity extends BridgeActivity {
isInForeground = true; isInForeground = true;
// Backstop for unlock flows that don't broadcast ACTION_USER_PRESENT // Backstop for unlock flows that don't broadcast ACTION_USER_PRESENT
// (some OEM biometric / insecure-keyguard dismissals): if we're over-lock // (some OEM biometric / insecure-keyguard dismissals): if we're over-lock
// but the keyguard is gone, leave over-lock so the overlay drops. // but the keyguard is gone, leave over-lock so the overlay drops. Ignore an
if (overLock && !isKeyguardLockedNow()) { // "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(); exitOverLock();
OverLockCallPlugin.notifyUnlocked(); OverLockCallPlugin.notifyUnlocked();
} }

View file

@ -59,6 +59,18 @@ final class PushStrings {
return forAppLocale(ctx).getString(R.string.push_missed_call); 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) { static String missedCallBody(Context ctx, String caller) {
String safeCaller = caller == null ? "" : caller; String safeCaller = caller == null ? "" : caller;
return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller); return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller);

View file

@ -290,7 +290,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
volatile long lastAlertedAt; volatile long lastAlertedAt;
IncomingRing(Map<String, String> data, String messageId, long seededAt) { IncomingRing(Map<String, String> 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.messageId = messageId;
this.seededAt = seededAt; this.seededAt = seededAt;
this.renderedAt = 0L; this.renderedAt = 0L;
@ -1821,16 +1827,29 @@ public class VojoFirebaseMessagingService extends MessagingService {
parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS)) parseLong(entry.data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS))
+ RTC_LIFETIME_GRACE_MS; + RTC_LIFETIME_GRACE_MS;
new Handler(Looper.getMainLooper()).post(() -> { new Handler(Looper.getMainLooper()).post(() -> {
// Re-check the slot: a decline/expiry/supersede could have run // Re-check AND start the whole session UNDER telecomLock (F14 / ORANGE-7).
// maybeTeardownTelecomRing in the post window (clearing the slot). // The previous fix only re-checked the slot under the lock, then released
// Creating the session now would orphan it and wedge the single-session // it before startForegroundService + startCall + acquireRingCeiling. A
// slot forever, so abort instead. // 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) { synchronized (telecomLock) {
if (!eventId.equals(telecomRingEventId)) { if (!eventId.equals(telecomRingEventId)) {
dlog("telecom: ring removed before startCall, aborting event=" + eventId); dlog("telecom: ring removed before startCall, aborting event=" + eventId);
return; return;
} }
}
// phoneCall-only FGS process retention + answer-from-killed pre-arm. // phoneCall-only FGS process retention + answer-from-killed pre-arm.
try { try {
Intent fgs = new Intent(appCtx, CallForegroundService.class) Intent fgs = new Intent(appCtx, CallForegroundService.class)
@ -1851,12 +1870,11 @@ public class VojoFirebaseMessagingService extends MessagingService {
acquireRingCeiling(appCtx, eventId, ringCeilingMs); acquireRingCeiling(appCtx, eventId, ringCeilingMs);
} catch (Throwable t) { } catch (Throwable t) {
Log.w(TAG, "telecom: startCall(incoming) failed", t); Log.w(TAG, "telecom: startCall(incoming) failed", t);
synchronized (telecomLock) {
if (eventId.equals(telecomRingEventId)) telecomRingEventId = null; if (eventId.equals(telecomRingEventId)) telecomRingEventId = null;
releaseRingCeilingLocked(); releaseRingCeilingLocked();
}
stopRingForegroundService(appCtx); stopRingForegroundService(appCtx);
} }
}
}); });
} }
@ -2032,8 +2050,25 @@ public class VojoFirebaseMessagingService extends MessagingService {
@Override @Override
public void onError(String message) { public void onError(String message) {
Log.w(TAG, "telecom: native ring error: " + message); // The Telecom path is unavailable for THIS ring (register / addCall
removeIncomingRing(appCtx, eventId); // 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() { } @Override public void onSetActive() { }
@ -2114,9 +2149,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
ctx, declineReq, roomId, notifEventId, tag, notifId ctx, declineReq, roomId, notifEventId, tag, notifId
); );
// Body-tap + full-screen intent the native full-screen IncomingCallActivity // Body-tap + full-screen intent the native full-screen IncomingCallActivity
// over the lockscreen (Telecom is the sole call backend now). // over the lockscreen (Telecom is the sole call backend now). Pass the
PendingIntent launchPI = // clamped ring lifetime so the Activity's self-finish safety timeout tracks
buildIncomingActivityPI(ctx, launchReq, roomId, notifEventId, messageId, callerName); // 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(); 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 // full-screen IncomingCallActivity: request over-lock so a locked device
// answers without a forced unlock. MainActivity re-checks the keyguard, // answers without a forced unlock. MainActivity re-checks the keyguard,
// so this is a no-op when the device is already unlocked. // 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 int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
return PendingIntent.getActivity(ctx, requestCode, intent, flags); return PendingIntent.getActivity(ctx, requestCode, intent, flags);
@ -2231,7 +2275,8 @@ public class VojoFirebaseMessagingService extends MessagingService {
String roomId, String roomId,
String notifEventId, String notifEventId,
String messageId, String messageId,
String callerName String callerName,
long ringLifetimeMs
) { ) {
Intent intent = new Intent(ctx, IncomingCallActivity.class) Intent intent = new Intent(ctx, IncomingCallActivity.class)
.setAction(Intent.ACTION_MAIN) .setAction(Intent.ACTION_MAIN)
@ -2240,6 +2285,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
intent.putExtra("notif_event_id", notifEventId); intent.putExtra("notif_event_id", notifEventId);
intent.putExtra("google.message_id", messageId != null ? messageId : ""); intent.putExtra("google.message_id", messageId != null ? messageId : "");
intent.putExtra("caller_name", callerName != null ? callerName : ""); intent.putExtra("caller_name", callerName != null ? callerName : "");
intent.putExtra("content_lifetime", ringLifetimeMs);
int flags = PendingIntent.FLAG_UPDATE_CURRENT int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
return PendingIntent.getActivity(ctx, requestCode, intent, flags); return PendingIntent.getActivity(ctx, requestCode, intent, flags);

View file

@ -4,4 +4,7 @@
<string name="call_decline">Отклонить</string> <string name="call_decline">Отклонить</string>
<string name="call_incoming">Входящий звонок</string> <string name="call_incoming">Входящий звонок</string>
<string name="call_unknown_caller">Неизвестный абонент</string> <string name="call_unknown_caller">Неизвестный абонент</string>
<string name="call_ongoing_title">Активный звонок</string>
<string name="call_ongoing_channel_name">Активные звонки</string>
<string name="call_ongoing_channel_description">Уведомление во время активного звонка Vojo</string>
</resources> </resources>

View file

@ -9,4 +9,8 @@
<string name="call_decline">Decline</string> <string name="call_decline">Decline</string>
<string name="call_incoming">Incoming call</string> <string name="call_incoming">Incoming call</string>
<string name="call_unknown_caller">Unknown caller</string> <string name="call_unknown_caller">Unknown caller</string>
<!-- Active-call foreground-service notification + channel (ru in values-ru). -->
<string name="call_ongoing_title">Ongoing call</string>
<string name="call_ongoing_channel_name">Ongoing calls</string>
<string name="call_ongoing_channel_description">Notification shown during an active Vojo call</string>
</resources> </resources>

View file

@ -532,7 +532,6 @@
"unavailable": "Calls are unavailable", "unavailable": "Calls are unavailable",
"incoming": "Incoming call…", "incoming": "Incoming call…",
"incoming_label": "Incoming call", "incoming_label": "Incoming call",
"accept": "Accept",
"answer": "Answer", "answer": "Answer",
"decline": "Decline", "decline": "Decline",
"unknown_caller": "Unknown caller", "unknown_caller": "Unknown caller",

View file

@ -536,7 +536,6 @@
"unavailable": "Звонки недоступны", "unavailable": "Звонки недоступны",
"incoming": "Входящий звонок…", "incoming": "Входящий звонок…",
"incoming_label": "Входящий звонок", "incoming_label": "Входящий звонок",
"accept": "Принять",
"answer": "Ответить", "answer": "Ответить",
"decline": "Отклонить", "decline": "Отклонить",
"unknown_caller": "Неизвестный абонент", "unknown_caller": "Неизвестный абонент",

View file

@ -61,10 +61,9 @@ export const useAndroidCallForegroundSync = (): void => {
// Tag the active-call FGS with both types: microphone for §2.2 mic // 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 // retention under lock, and phoneCall so it matches the ring-time phoneCall
// FGS (clean handoff on answer-from-killed) and stays restartable if the // FGS (clean handoff on answer-from-killed) and stays restartable if the
// process is rebuilt mid-call. // process is rebuilt mid-call. No title → native fills the localized
callForegroundService // "Ongoing call" (PushStrings), instead of a hardcoded Russian string (F32).
.start({ title: 'Активный звонок', phoneCall: true }) callForegroundService.start({ phoneCall: true }).catch((err: unknown) => {
.catch((err: unknown) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[call-fgs] start failed', err); console.warn('[call-fgs] start failed', err);
}); });

View file

@ -34,6 +34,7 @@ import { useRoomNavigate } from './useRoomNavigate';
import { polling, type RoomMetadataMap, type UserAvatarsMap } from '../plugins/polling'; import { polling, type RoomMetadataMap, type UserAvatarsMap } from '../plugins/polling';
import { getAccountData, getMDirects } from '../utils/room'; import { getAccountData, getMDirects } from '../utils/room';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { callForegroundService } from '../plugins/call/callForegroundService';
const noop = (): void => undefined; const noop = (): void => undefined;
@ -617,6 +618,9 @@ export function usePushNotificationsLifecycle(): void {
room_id?: string; room_id?: string;
call_action?: 'answer' | 'decline'; call_action?: 'answer' | 'decline';
notif_event_id?: string; 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 // Sygnal flattens nested fields with `_` separator; the Android
// FCM service forwards every data entry verbatim into the launch // FCM service forwards every data entry verbatim into the launch
// intent (VojoFirebaseMessagingService.java foreach), so these // intent (VojoFirebaseMessagingService.java foreach), so these
@ -643,12 +647,30 @@ export function usePushNotificationsLifecycle(): void {
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs // route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
// it doesn't have in its left-rail, hence the DM path here. // it doesn't have in its left-rail, hence the DM path here.
if (data.call_action === 'answer' && data.room_id) { if (data.call_action === 'answer' && data.room_id) {
navigate(getDirectRoomPath(data.room_id), { replace: true }); const answerRoomId = data.room_id;
const answerNotifEventId = data.notif_event_id;
const dispatchAnswer = () => {
navigate(getDirectRoomPath(answerRoomId), { replace: true });
setPendingCallAction({ setPendingCallAction({
kind: 'answer', kind: 'answer',
roomId: data.room_id, roomId: answerRoomId,
notifEventId: data.notif_event_id, 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; return;
} }

View file

@ -36,6 +36,7 @@ interface CallForegroundServicePlugin {
upsertIncomingRing(options: IncomingRingUpsert): Promise<void>; upsertIncomingRing(options: IncomingRingUpsert): Promise<void>;
removeIncomingRing(options: { eventId: string }): Promise<void>; removeIncomingRing(options: { eventId: string }): Promise<void>;
clearAllIncomingRings(): Promise<void>; clearAllIncomingRings(): Promise<void>;
verifyActionToken(options: { token?: string }): Promise<{ valid: boolean }>;
} }
const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService'); const plugin = registerPlugin<CallForegroundServicePlugin>('CallForegroundService');
@ -77,4 +78,13 @@ export const callForegroundService = {
if (!isAndroidPlatform()) return Promise.resolve(); if (!isAndroidPlatform()) return Promise.resolve();
return plugin.clearAllIncomingRings(); 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<boolean> {
if (!isAndroidPlatform()) return Promise.resolve(true);
return plugin.verifyActionToken({ token }).then((r) => r.valid);
},
}; };