feat: implement non-exported CallNotificationTrampolineActivity for secure call handling and update AndroidNotificationBridge with trusted call action management

This commit is contained in:
Ivan 2026-07-18 15:08:33 -05:00
parent 37b3f26669
commit 65f1b2cc77
No known key found for this signature in database
7 changed files with 211 additions and 39 deletions

View file

@ -90,6 +90,18 @@
android:resource="@xml/device_filter" />
</activity>
<!--
Non-exported: notification answer/decline PendingIntents only.
Keeps forgeable CALL_ANSWER / CALL_DECLINE off the exported MainActivity.
-->
<activity
android:name=".CallNotificationTrampolineActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<service
android:name=".MeshChatForegroundService"
android:exported="false"

View file

@ -21,20 +21,80 @@ public final class AndroidNotificationBridge {
public static final String ACTION_CALL_DECLINE = "com.meshchatx.action.CALL_DECLINE";
public static final String ACTION_CALL_OPEN = "com.meshchatx.action.CALL_OPEN";
/** Values stored for {@link #offerTrustedCallAction(String)} / {@link #takeTrustedCallAction()}. */
public static final String TRUSTED_CALL_ACTION_ANSWER = "answer";
public static final String TRUSTED_CALL_ACTION_DECLINE = "decline";
public static final String TRUSTED_CALL_ACTION_OPEN = "open";
private static final int REQ_CALL_OPEN = 0x4c31;
private static final int REQ_CALL_FULL = 0x4c32;
private static final int REQ_CALL_ANSWER = 0x4c33;
private static final int REQ_CALL_DECLINE = 0x4c34;
private static final Object CONTEXT_LOCK = new Object();
private static final Object TRUSTED_CALL_LOCK = new Object();
private static volatile boolean doNotDisturbEnabled = false;
private static volatile String openConversationHashesCsv = "";
private static volatile String pendingTrustedCallAction = null;
private static final java.util.Set<Integer> postedMessageNotificationIds =
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
private AndroidNotificationBridge() {
}
/**
* Map a notification intent action to a trusted call-control token, or null if unknown.
*/
@Nullable
public static String mapCallNotificationAction(@Nullable String action) {
if (ACTION_CALL_ANSWER.equals(action)) {
return TRUSTED_CALL_ACTION_ANSWER;
}
if (ACTION_CALL_DECLINE.equals(action)) {
return TRUSTED_CALL_ACTION_DECLINE;
}
if (ACTION_CALL_OPEN.equals(action)) {
return TRUSTED_CALL_ACTION_OPEN;
}
return null;
}
/**
* Queue a call action produced only by the non-exported notification trampoline.
*/
public static void offerTrustedCallAction(@Nullable String action) {
if (action == null) {
return;
}
if (!TRUSTED_CALL_ACTION_ANSWER.equals(action)
&& !TRUSTED_CALL_ACTION_DECLINE.equals(action)
&& !TRUSTED_CALL_ACTION_OPEN.equals(action)) {
return;
}
synchronized (TRUSTED_CALL_LOCK) {
pendingTrustedCallAction = action;
}
}
/**
* Consume the queued trusted call action (one-shot).
*/
@Nullable
public static String takeTrustedCallAction() {
synchronized (TRUSTED_CALL_LOCK) {
String action = pendingTrustedCallAction;
pendingTrustedCallAction = null;
return action;
}
}
/** Test helper: clear any queued trusted action. */
public static void clearTrustedCallActionForTests() {
synchronized (TRUSTED_CALL_LOCK) {
pendingTrustedCallAction = null;
}
}
public static void setDoNotDisturbEnabled(boolean enabled) {
doNotDisturbEnabled = enabled;
}
@ -194,42 +254,33 @@ public final class AndroidNotificationBridge {
}
private static Intent callIntent(Context ctx, String action) {
Intent i = new Intent(ctx, MainActivity.class);
// Answer/decline must hit the non-exported trampoline. Content open may
// use MainActivity (UI only), but still prefer the trampoline so one path
// owns notification call intents.
Intent i = new Intent(ctx, CallNotificationTrampolineActivity.class);
i.setAction(action);
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
return i;
}
private static PendingIntent callPendingIntent(Context ctx, int requestCode, String action) {
return PendingIntent.getActivity(
ctx,
requestCode,
callIntent(ctx, action),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
private static void postIncomingCall(Context ctx, String callerName, @Nullable String dedupeHex) {
NotificationManager nm = ctx.getSystemService(NotificationManager.class);
if (nm == null) {
return;
}
PendingIntent open = PendingIntent.getActivity(
ctx,
REQ_CALL_OPEN,
callIntent(ctx, ACTION_CALL_OPEN),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
PendingIntent full = PendingIntent.getActivity(
ctx,
REQ_CALL_FULL,
callIntent(ctx, ACTION_CALL_OPEN),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
PendingIntent answer = PendingIntent.getActivity(
ctx,
REQ_CALL_ANSWER,
callIntent(ctx, ACTION_CALL_ANSWER),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
PendingIntent decline = PendingIntent.getActivity(
ctx,
REQ_CALL_DECLINE,
callIntent(ctx, ACTION_CALL_DECLINE),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
PendingIntent open = callPendingIntent(ctx, REQ_CALL_OPEN, ACTION_CALL_OPEN);
PendingIntent full = callPendingIntent(ctx, REQ_CALL_FULL, ACTION_CALL_OPEN);
PendingIntent answer = callPendingIntent(ctx, REQ_CALL_ANSWER, ACTION_CALL_ANSWER);
PendingIntent decline = callPendingIntent(ctx, REQ_CALL_DECLINE, ACTION_CALL_DECLINE);
Person person = new Person.Builder().setName(callerName).setImportant(true).build();
String incomingLabel = ctx.getString(R.string.notification_incoming_call_label, callerName);
@ -297,9 +348,8 @@ public final class AndroidNotificationBridge {
return;
}
Intent open = new Intent(ctx, MainActivity.class);
Intent open = new Intent(ctx, CallNotificationTrampolineActivity.class);
open.setAction(ACTION_CALL_OPEN);
open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pi = PendingIntent.getActivity(
ctx,
REQ_CALL_OPEN,

View file

@ -0,0 +1,38 @@
package com.meshchatx;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
/**
* Non-exported trampoline for incoming-call notification actions.
*
* <p>PendingIntents for answer/decline must not target the exported {@link MainActivity}.
* Same-profile apps can forge intents to exported activities. This component stays
* package-private so only our notification PendingIntents can start it.
*/
public final class CallNotificationTrampolineActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent inbound = getIntent();
String action = inbound != null ? inbound.getAction() : null;
String trusted = AndroidNotificationBridge.mapCallNotificationAction(action);
if (trusted != null) {
AndroidNotificationBridge.offerTrustedCallAction(trusted);
}
Intent main = new Intent(this, MainActivity.class);
main.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP
);
try {
startActivity(main);
} catch (Exception ignored) {
}
finish();
}
}

View file

@ -767,16 +767,11 @@ public class MainActivity extends AppCompatActivity {
}
private void consumeCallIntentForPending(Intent intent) {
if (intent == null) {
return;
}
String a = intent.getAction();
if (AndroidNotificationBridge.ACTION_CALL_ANSWER.equals(a)) {
pendingCallNotificationAction = "answer";
} else if (AndroidNotificationBridge.ACTION_CALL_DECLINE.equals(a)) {
pendingCallNotificationAction = "decline";
} else if (AndroidNotificationBridge.ACTION_CALL_OPEN.equals(a)) {
pendingCallNotificationAction = "open";
// Answer / decline / open are queued only by CallNotificationTrampolineActivity
// (exported=false). Ignore CALL_ANSWER / CALL_DECLINE on this exported activity.
String trusted = AndroidNotificationBridge.takeTrustedCallAction();
if (trusted != null) {
pendingCallNotificationAction = trusted;
}
}

View file

@ -0,0 +1,77 @@
package com.meshchatx;
import org.junit.After;
import org.junit.Assert;
import org.junit.Test;
/**
* Trusted call-notification actions must not be forgeable via exported MainActivity.
*/
public class CallNotificationTrustedActionTest {
@After
public void tearDown() {
AndroidNotificationBridge.clearTrustedCallActionForTests();
}
@Test
public void mapCallNotificationActionRecognizesAnswerDeclineOpen() {
Assert.assertEquals(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER,
AndroidNotificationBridge.mapCallNotificationAction(
AndroidNotificationBridge.ACTION_CALL_ANSWER
)
);
Assert.assertEquals(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_DECLINE,
AndroidNotificationBridge.mapCallNotificationAction(
AndroidNotificationBridge.ACTION_CALL_DECLINE
)
);
Assert.assertEquals(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_OPEN,
AndroidNotificationBridge.mapCallNotificationAction(
AndroidNotificationBridge.ACTION_CALL_OPEN
)
);
Assert.assertNull(AndroidNotificationBridge.mapCallNotificationAction(null));
Assert.assertNull(AndroidNotificationBridge.mapCallNotificationAction("android.intent.action.VIEW"));
Assert.assertNull(
AndroidNotificationBridge.mapCallNotificationAction("com.meshchatx.action.CALL_ATTACK")
);
}
@Test
public void offerAndTakeTrustedCallActionIsOneShot() {
AndroidNotificationBridge.offerTrustedCallAction(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER
);
Assert.assertEquals(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER,
AndroidNotificationBridge.takeTrustedCallAction()
);
Assert.assertNull(AndroidNotificationBridge.takeTrustedCallAction());
}
@Test
public void offerTrustedCallActionRejectsUnknownTokens() {
AndroidNotificationBridge.offerTrustedCallAction("initiate");
AndroidNotificationBridge.offerTrustedCallAction("");
AndroidNotificationBridge.offerTrustedCallAction(null);
Assert.assertNull(AndroidNotificationBridge.takeTrustedCallAction());
}
@Test
public void latestTrustedCallActionWins() {
AndroidNotificationBridge.offerTrustedCallAction(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER
);
AndroidNotificationBridge.offerTrustedCallAction(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_DECLINE
);
Assert.assertEquals(
AndroidNotificationBridge.TRUSTED_CALL_ACTION_DECLINE,
AndroidNotificationBridge.takeTrustedCallAction()
);
}
}

Binary file not shown.

View file

@ -286,7 +286,7 @@ export function prepareVisualiserIconPixels(data, mode = "opaque") {
data[i + 3] = alpha;
if (alpha >= 24) glyphPixels += 1;
}
} else if (a === 0 && (r | g | b)) {
} else if (a === 0 && (r !== 0 || g !== 0 || b !== 0)) {
// Some RGB PNGs store color with a=0. Promote those only.
// Never crush existing soft alpha (RNS logo fringe looked jagged).
data[i + 3] = 255;