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;