mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: implement non-exported CallNotificationTrampolineActivity for secure call handling and update AndroidNotificationBridge with trusted call action management
This commit is contained in:
parent
37b3f26669
commit
65f1b2cc77
7 changed files with 211 additions and 39 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue