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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,41 +1827,53 @@ 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)
|
.putExtra(CallForegroundService.EXTRA_TITLE, callerName)
|
||||||
.putExtra(CallForegroundService.EXTRA_TITLE, callerName)
|
.putExtra(CallForegroundService.EXTRA_TYPE_PHONE_CALL, true)
|
||||||
.putExtra(CallForegroundService.EXTRA_TYPE_PHONE_CALL, true)
|
.putExtra(CallForegroundService.EXTRA_TYPE_MICROPHONE, false);
|
||||||
.putExtra(CallForegroundService.EXTRA_TYPE_MICROPHONE, false);
|
appCtx.startForegroundService(fgs);
|
||||||
appCtx.startForegroundService(fgs);
|
} catch (Throwable t) {
|
||||||
} catch (Throwable t) {
|
Log.w(TAG, "telecom: phoneCall FGS start failed", t);
|
||||||
Log.w(TAG, "telecom: phoneCall FGS start failed", t);
|
}
|
||||||
}
|
try {
|
||||||
try {
|
fmgr.setFallbackListener(nativeRingListener(appCtx, eventId, roomId, messageId));
|
||||||
fmgr.setFallbackListener(nativeRingListener(appCtx, eventId, roomId, messageId));
|
fmgr.startCall(roomId, callerName, false /* video */, true /* incoming */);
|
||||||
fmgr.startCall(roomId, callerName, false /* video */, true /* incoming */);
|
dlog("telecom: incoming session started event=" + eventId);
|
||||||
dlog("telecom: incoming session started event=" + eventId);
|
// Doze-proof in-process ceiling: hold a bounded wakelock + post a
|
||||||
// Doze-proof in-process ceiling: hold a bounded wakelock + post a
|
// teardown so this un-answered ring can't outlive its lifetime even
|
||||||
// teardown so this un-answered ring can't outlive its lifetime even
|
// under deep Doze (RED-1). Cancelled on answer/decline/supersede.
|
||||||
// under deep Doze (RED-1). Cancelled on answer/decline/supersede.
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -536,7 +536,6 @@
|
||||||
"unavailable": "Звонки недоступны",
|
"unavailable": "Звонки недоступны",
|
||||||
"incoming": "Входящий звонок…",
|
"incoming": "Входящий звонок…",
|
||||||
"incoming_label": "Входящий звонок",
|
"incoming_label": "Входящий звонок",
|
||||||
"accept": "Принять",
|
|
||||||
"answer": "Ответить",
|
"answer": "Ответить",
|
||||||
"decline": "Отклонить",
|
"decline": "Отклонить",
|
||||||
"unknown_caller": "Неизвестный абонент",
|
"unknown_caller": "Неизвестный абонент",
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,12 @@ 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);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
callForegroundService.stop().catch((err: unknown) => {
|
callForegroundService.stop().catch((err: unknown) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
setPendingCallAction({
|
const answerNotifEventId = data.notif_event_id;
|
||||||
kind: 'answer',
|
const dispatchAnswer = () => {
|
||||||
roomId: data.room_id,
|
navigate(getDirectRoomPath(answerRoomId), { replace: true });
|
||||||
notifEventId: data.notif_event_id,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue