diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0a694773..f29db13c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -90,6 +90,18 @@ android:resource="@xml/device_filter" /> + + + 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, diff --git a/android/app/src/main/java/com/meshchatx/CallNotificationTrampolineActivity.java b/android/app/src/main/java/com/meshchatx/CallNotificationTrampolineActivity.java new file mode 100644 index 00000000..25270b2e --- /dev/null +++ b/android/app/src/main/java/com/meshchatx/CallNotificationTrampolineActivity.java @@ -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. + * + *

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(); + } +} diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java index 721792ed..84b5e8f0 100644 --- a/android/app/src/main/java/com/meshchatx/MainActivity.java +++ b/android/app/src/main/java/com/meshchatx/MainActivity.java @@ -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; } } diff --git a/android/app/src/test/java/com/meshchatx/CallNotificationTrustedActionTest.java b/android/app/src/test/java/com/meshchatx/CallNotificationTrustedActionTest.java new file mode 100644 index 00000000..4eebbd1d --- /dev/null +++ b/android/app/src/test/java/com/meshchatx/CallNotificationTrustedActionTest.java @@ -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() + ); + } +} diff --git a/meshchatx.rsm b/meshchatx.rsm index 4ffe5c6f..1a418ad4 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js index da2ee5d8..bad255be 100644 --- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js +++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js @@ -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;