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:
parent
64517a73f7
commit
71a71fa56b
14 changed files with 318 additions and 69 deletions
75
android/app/src/main/java/chat/vojo/app/CallActionToken.java
Normal file
75
android/app/src/main/java/chat/vojo/app/CallActionToken.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -290,7 +290,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
volatile long lastAlertedAt;
|
||||
|
||||
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.seededAt = seededAt;
|
||||
this.renderedAt = 0L;
|
||||
|
|
@ -1821,16 +1827,29 @@ 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)
|
||||
|
|
@ -1851,12 +1870,11 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
acquireRingCeiling(appCtx, eventId, ringCeilingMs);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "telecom: startCall(incoming) failed", t);
|
||||
synchronized (telecomLock) {
|
||||
if (eventId.equals(telecomRingEventId)) telecomRingEventId = null;
|
||||
releaseRingCeilingLocked();
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -4,4 +4,7 @@
|
|||
<string name="call_decline">Отклонить</string>
|
||||
<string name="call_incoming">Входящий звонок</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>
|
||||
|
|
|
|||
|
|
@ -9,4 +9,8 @@
|
|||
<string name="call_decline">Decline</string>
|
||||
<string name="call_incoming">Incoming call</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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -536,7 +536,6 @@
|
|||
"unavailable": "Звонки недоступны",
|
||||
"incoming": "Входящий звонок…",
|
||||
"incoming_label": "Входящий звонок",
|
||||
"accept": "Принять",
|
||||
"answer": "Ответить",
|
||||
"decline": "Отклонить",
|
||||
"unknown_caller": "Неизвестный абонент",
|
||||
|
|
|
|||
|
|
@ -61,10 +61,9 @@ 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) => {
|
||||
// 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
const answerRoomId = data.room_id;
|
||||
const answerNotifEventId = data.notif_event_id;
|
||||
const dispatchAnswer = () => {
|
||||
navigate(getDirectRoomPath(answerRoomId), { replace: true });
|
||||
setPendingCallAction({
|
||||
kind: 'answer',
|
||||
roomId: data.room_id,
|
||||
notifEventId: data.notif_event_id,
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ interface CallForegroundServicePlugin {
|
|||
upsertIncomingRing(options: IncomingRingUpsert): Promise<void>;
|
||||
removeIncomingRing(options: { eventId: string }): Promise<void>;
|
||||
clearAllIncomingRings(): Promise<void>;
|
||||
verifyActionToken(options: { token?: string }): Promise<{ valid: boolean }>;
|
||||
}
|
||||
|
||||
const plugin = registerPlugin<CallForegroundServicePlugin>('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<boolean> {
|
||||
if (!isAndroidPlatform()) return Promise.resolve(true);
|
||||
return plugin.verifyActionToken({ token }).then((r) => r.valid);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue