feat(push): inline RemoteInput reply on per-room notifications for cleartext rooms with optimistic local echo and encryption-state re-dump
This commit is contained in:
parent
8a80194fe5
commit
06778702b2
9 changed files with 430 additions and 15 deletions
|
|
@ -117,6 +117,10 @@
|
|||
<receiver
|
||||
android:name=".NotificationDismissReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".ReplyReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
|
|
|||
|
|
@ -82,6 +82,18 @@ final class PushStrings {
|
|||
return forAppLocale(ctx).getString(R.string.push_action_mark_as_read);
|
||||
}
|
||||
|
||||
static String replyAction(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_action_reply);
|
||||
}
|
||||
|
||||
static String replyHint(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_reply_hint);
|
||||
}
|
||||
|
||||
static String replyFailed(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_reply_failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the invite-notification body from inviter + room name, falling
|
||||
* back through four variants when one or both are absent. The res IDs
|
||||
|
|
|
|||
200
android/app/src/main/java/chat/vojo/app/ReplyReceiver.java
Normal file
200
android/app/src/main/java/chat/vojo/app/ReplyReceiver.java
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.RemoteInput;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Handles the inline-reply RemoteInput action on a per-room MessagingStyle
|
||||
* notification.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User taps reply, types text, presses send → broadcast fires here.
|
||||
* 2. We immediately append the outgoing message to RoomMessageCache and
|
||||
* re-post the notification (instant UX feedback — the message appears
|
||||
* as a self-Person bubble in the conversation while the HTTP is in
|
||||
* flight).
|
||||
* 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
|
||||
* with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same
|
||||
* storage as Worker / MarkAsReadReceiver — single credential lifecycle).
|
||||
* 4. On 2xx: nothing further; the JS sync echo will eventually replace
|
||||
* the local-echo bubble in-app.
|
||||
* 5. On non-2xx or thrown: post a small error notification "Could not
|
||||
* send your reply" so the user knows to retry from in-app — better
|
||||
* than silently swallowing the message.
|
||||
*
|
||||
* E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService.
|
||||
* renderMessageNotification: we don't even attach the reply action when
|
||||
* RoomMetadata.isEncrypted is true. So this receiver never has to encrypt.
|
||||
* Defense in depth: if a stale notification with the action ever survives
|
||||
* an encryption flip we still detect the failure as a non-2xx HTTP and
|
||||
* surface the error notification rather than sending cleartext (which
|
||||
* Synapse would in any case reject for an encrypted room).
|
||||
*
|
||||
* Null-credential edge case: post the error notification so the user
|
||||
* notices and retries in-app. Same logic as a network failure.
|
||||
*/
|
||||
public class ReplyReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_REPLY = "chat.vojo.app.REPLY";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
public static final String KEY_TEXT_REPLY = "vojo.text_reply";
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||
private static final int READ_TIMEOUT_MS = 8_000;
|
||||
private static final String TAG = "ReplyRcvr";
|
||||
|
||||
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: missing room_id, abort");
|
||||
return;
|
||||
}
|
||||
|
||||
Bundle remote = RemoteInput.getResultsFromIntent(intent);
|
||||
if (remote == null) {
|
||||
Log.w(TAG, "onReceive: no RemoteInput results");
|
||||
return;
|
||||
}
|
||||
CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY);
|
||||
if (reply == null) {
|
||||
Log.w(TAG, "onReceive: RemoteInput missing text");
|
||||
return;
|
||||
}
|
||||
final String text = reply.toString().trim();
|
||||
if (text.isEmpty()) return;
|
||||
|
||||
final Context appContext = context.getApplicationContext();
|
||||
|
||||
// Optimistic local echo — appends a self-Person message to the
|
||||
// conversation and re-posts, so the user sees their reply in the
|
||||
// shade before the HTTP completes.
|
||||
long now = System.currentTimeMillis();
|
||||
VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
|
||||
|
||||
final SharedPreferences prefs = appContext.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif");
|
||||
postReplyError(appContext, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
final PendingResult pendingResult = goAsync();
|
||||
final String txnId = "vojo-reply-" + UUID.randomUUID();
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
int status = sendReply(homeserver, token, roomId, txnId, text);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId);
|
||||
postReplyError(appContext, roomId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "reply threw room=" + roomId, t);
|
||||
postReplyError(appContext, roomId);
|
||||
} finally {
|
||||
pendingResult.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int sendReply(
|
||||
String baseUrl,
|
||||
String accessToken,
|
||||
String roomId,
|
||||
String txnId,
|
||||
String text
|
||||
) throws IOException {
|
||||
String url = trimTrailingSlash(baseUrl)
|
||||
+ "/_matrix/client/v3/rooms/"
|
||||
+ URLEncoder.encode(roomId, "UTF-8")
|
||||
+ "/send/m.room.message/"
|
||||
+ URLEncoder.encode(txnId, "UTF-8");
|
||||
|
||||
JSONObject body;
|
||||
try {
|
||||
body = new JSONObject();
|
||||
body.put("msgtype", "m.text");
|
||||
body.put("body", text);
|
||||
} catch (org.json.JSONException je) {
|
||||
// JSONObject.put only throws on NaN/Inf doubles, neither of
|
||||
// which we use — but keep the type contract honest.
|
||||
throw new IOException("payload encode failed", je);
|
||||
}
|
||||
byte[] payload = body.toString().getBytes("UTF-8");
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("PUT");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
conn.setFixedLengthStreamingMode(payload.length);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(payload);
|
||||
}
|
||||
return conn.getResponseCode();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a short error notification when the reply HTTP fails so the
|
||||
* user knows the message did NOT land server-side and can retry from
|
||||
* within the app. Posted on the DM channel as a one-shot. Unique notif
|
||||
* id per room so it can't clobber the room's conversation slot.
|
||||
*/
|
||||
private static void postReplyError(Context ctx, String roomId) {
|
||||
NotificationManager nm = (NotificationManager)
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (nm == null) return;
|
||||
try {
|
||||
String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM_PUBLIC;
|
||||
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(PushStrings.replyFailed(ctx))
|
||||
.setContentText(PushStrings.replyFailed(ctx))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
int errId = ("replyErr_" + roomId).hashCode();
|
||||
nm.notify(errId, b.build());
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "reply error notif failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import android.util.Log;
|
|||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.Person;
|
||||
import androidx.core.app.RemoteInput;
|
||||
|
||||
import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
|
|
@ -67,6 +68,10 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
private static final String MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1";
|
||||
private static final String CHANNEL_ID_DM = "vojo_messages_dm_v1";
|
||||
private static final String CHANNEL_ID_GROUP = "vojo_messages_group_v1";
|
||||
// Package-visible mirror of CHANNEL_ID_DM so ReplyReceiver can post its
|
||||
// one-shot "reply failed" error notification on the same channel without
|
||||
// duplicating the literal.
|
||||
static final String CHANNEL_ID_DM_PUBLIC = CHANNEL_ID_DM;
|
||||
|
||||
private static final String GROUP_KEY = "vojo_messages";
|
||||
// NotificationChannel settings (vibration pattern, sound, importance) are
|
||||
|
|
@ -491,6 +496,15 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
.addAction(buildMarkAsReadAction(ctx, roomId, eventId));
|
||||
|
||||
// Inline reply is only safe in cleartext rooms — the receiver
|
||||
// builds a vanilla `m.room.message`, and we have no key material
|
||||
// on the Java side to encrypt. RoomMetadata.isEncrypted defaults
|
||||
// to true on missing/legacy snapshots (privacy-first), so reply
|
||||
// is opt-in via a confirmed cleartext flag rather than opt-out.
|
||||
if (!meta.isEncrypted) {
|
||||
builder.addAction(buildReplyAction(ctx, roomId));
|
||||
}
|
||||
|
||||
dlog("msg: posting notif id=" + notifId + " channel=" + channelId
|
||||
+ " historySize=" + history.size()
|
||||
+ " notifsEnabled=" + nm.areNotificationsEnabled());
|
||||
|
|
@ -655,6 +669,137 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline reply action with RemoteInput. Tapping the action opens an
|
||||
* in-shade text field; submit fires the receiver and PUTs the message
|
||||
* as `m.room.message` from the cleartext path. Only attached for
|
||||
* non-encrypted rooms (see caller gate); E2EE replies need keys the
|
||||
* Java side does not have.
|
||||
*
|
||||
* MutabilityCompat: RemoteInput requires the PendingIntent to be
|
||||
* MUTABLE so the input bundle can be attached at submit time. We OR
|
||||
* FLAG_MUTABLE on API 31+ where the immutable default would otherwise
|
||||
* cause the receiver to fire without any RemoteInput payload.
|
||||
*/
|
||||
private static NotificationCompat.Action buildReplyAction(Context ctx, String roomId) {
|
||||
Intent intent = new Intent(ctx, ReplyReceiver.class)
|
||||
.setAction(ReplyReceiver.ACTION_REPLY)
|
||||
.putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId);
|
||||
int requestCode = ("reply_" + roomId).hashCode();
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
flags |= PendingIntent.FLAG_MUTABLE;
|
||||
}
|
||||
PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags);
|
||||
RemoteInput remoteInput = new RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY)
|
||||
.setLabel(PushStrings.replyHint(ctx))
|
||||
.build();
|
||||
return new NotificationCompat.Action.Builder(
|
||||
R.mipmap.ic_launcher,
|
||||
PushStrings.replyAction(ctx),
|
||||
pi
|
||||
)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.setShowsUserInterface(false)
|
||||
// Allow Wear / Android Auto auto-replies for accessibility.
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a self-Person message to the room's MessagingStyle cache and
|
||||
* re-post the notification with the new entry visible. Used by
|
||||
* ReplyReceiver for instant optimistic feedback before the HTTP PUT
|
||||
* completes — the user sees their text in the conversation bubble
|
||||
* immediately, the live sync echo eventually replaces it with the
|
||||
* server-authoritative version when they next open the app.
|
||||
*
|
||||
* Returns true iff the notification re-render succeeded.
|
||||
*/
|
||||
static boolean appendOutgoingMessage(
|
||||
Context ctx, String roomId, String body, long timestamp
|
||||
) {
|
||||
if (roomId == null || roomId.isEmpty() || body == null || body.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
NotificationManager nm = (NotificationManager)
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (nm == null) return false;
|
||||
RoomMetadata meta = loadRoomMetadata(ctx, roomId);
|
||||
String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP;
|
||||
ensureMessageChannels(ctx, nm);
|
||||
|
||||
// Re-seed from active notification before append so the new self
|
||||
// message lands at the tail of any in-flight conversation history
|
||||
// (covers the cold-render-after-kill case for outgoing replies).
|
||||
seedCacheFromActiveNotification(ctx, nm, roomId);
|
||||
|
||||
RoomMessageCache.Entry self = new RoomMessageCache.Entry(
|
||||
body, timestamp, /*senderKey*/ null, /*senderName*/ "", /*fromSelf*/ true
|
||||
);
|
||||
java.util.List<RoomMessageCache.Entry> history = RoomMessageCache.append(roomId, self);
|
||||
|
||||
Person selfPerson = buildSelfPerson(ctx);
|
||||
NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(selfPerson);
|
||||
boolean isGroup = !meta.isDirect;
|
||||
if (isGroup && !meta.name.isEmpty()) {
|
||||
style.setConversationTitle(meta.name);
|
||||
}
|
||||
style.setGroupConversation(isGroup);
|
||||
for (RoomMessageCache.Entry e : history) {
|
||||
Person sender = e.fromSelf ? null : new Person.Builder()
|
||||
.setName(e.senderName != null ? e.senderName : "")
|
||||
.setKey(e.senderKey != null ? e.senderKey : "")
|
||||
.build();
|
||||
style.addMessage(new NotificationCompat.MessagingStyle.Message(
|
||||
e.body != null ? e.body : "", e.timestamp, sender
|
||||
));
|
||||
}
|
||||
|
||||
Intent launchIntent = new Intent(ctx, MainActivity.class);
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
launchIntent.putExtra("google.message_id", "");
|
||||
launchIntent.putExtra("room_id", roomId);
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
PendingIntent contentIntent = PendingIntent.getActivity(
|
||||
ctx, ("open_" + roomId).hashCode(), launchIntent, flags
|
||||
);
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setStyle(style)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.setDeleteIntent(buildDismissPendingIntent(ctx, roomId))
|
||||
.setGroup(GROUP_KEY)
|
||||
// Always silent — sending a reply must not re-alert the user.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShortcutId(roomId)
|
||||
.setWhen(timestamp)
|
||||
.setShowWhen(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
// Re-attach mark-as-read so the action set stays consistent on
|
||||
// re-render; eventId is unknown for an outgoing-only update so
|
||||
// it falls through to the local-dismiss-only branch in the
|
||||
// receiver — acceptable for an optimistic echo.
|
||||
.addAction(buildMarkAsReadAction(ctx, roomId, null));
|
||||
if (!meta.isEncrypted) {
|
||||
builder.addAction(buildReplyAction(ctx, roomId));
|
||||
}
|
||||
|
||||
try {
|
||||
nm.notify(roomNotifId(roomId), builder.build());
|
||||
return true;
|
||||
} catch (SecurityException e) {
|
||||
Log.e(TAG, "outgoing: nm.notify threw SecurityException", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Person buildSelfPerson(Context ctx) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
|
|
@ -712,36 +857,54 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
/**
|
||||
* Snapshot of per-room metadata bridged from JS via
|
||||
* PollingPlugin.saveRoomNames (which now accepts both a legacy
|
||||
* room_id → name string map AND a structured shape including isDirect).
|
||||
* room_id → name string map AND a structured shape including isDirect
|
||||
* and isEncrypted). isEncrypted controls whether the inline reply
|
||||
* action is offered — we can't sign + encrypt without keys on the Java
|
||||
* side, so encrypted rooms get a read-only notification.
|
||||
*/
|
||||
private static final class RoomMetadata {
|
||||
final String name;
|
||||
final boolean isDirect;
|
||||
final boolean isEncrypted;
|
||||
|
||||
RoomMetadata(String name, boolean isDirect) {
|
||||
RoomMetadata(String name, boolean isDirect, boolean isEncrypted) {
|
||||
this.name = name == null ? "" : name;
|
||||
this.isDirect = isDirect;
|
||||
this.isEncrypted = isEncrypted;
|
||||
}
|
||||
}
|
||||
|
||||
private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) {
|
||||
if (roomId == null || roomId.isEmpty()) return new RoomMetadata("", true);
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
return new RoomMetadata("", true, false);
|
||||
}
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
|
||||
if (raw == null || raw.isEmpty()) return new RoomMetadata("", true);
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return new RoomMetadata("", true, false);
|
||||
}
|
||||
try {
|
||||
JSONObject map = new JSONObject(raw);
|
||||
if (!map.has(roomId) || map.isNull(roomId)) return new RoomMetadata("", true);
|
||||
if (!map.has(roomId) || map.isNull(roomId)) {
|
||||
return new RoomMetadata("", true, false);
|
||||
}
|
||||
// Legacy shape: { roomId: "Display name" }. New shape:
|
||||
// { roomId: { name: "Display name", isDirect: bool } }. Parse
|
||||
// tolerantly so an APK that still has the old map written to
|
||||
// prefs from a previous boot doesn't lose channel routing.
|
||||
// { roomId: { name: "Display name", isDirect: bool,
|
||||
// isEncrypted: bool } }. Parse tolerantly so an APK
|
||||
// that still has the old map written to prefs from a previous
|
||||
// boot doesn't lose channel routing.
|
||||
JSONObject obj = map.optJSONObject(roomId);
|
||||
if (obj != null) {
|
||||
String name = obj.optString("name", "");
|
||||
boolean isDirect = obj.optBoolean("isDirect", true);
|
||||
return new RoomMetadata(name, isDirect);
|
||||
// Default encrypted=true on missing flag: refusing to offer
|
||||
// reply on a falsely-flagged-encrypted room is harmless;
|
||||
// offering reply on a truly-encrypted room sends cleartext
|
||||
// into the timeline, which is a privacy leak. The conservative
|
||||
// direction is to assume encryption.
|
||||
boolean isEncrypted = obj.optBoolean("isEncrypted", true);
|
||||
return new RoomMetadata(name, isDirect, isEncrypted);
|
||||
}
|
||||
String legacyName = map.optString(roomId, "");
|
||||
// Default to DM when we have no isDirect signal. DM is the
|
||||
|
|
@ -750,9 +913,12 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
|||
// bad failure than under-alerting on a misclassified DM, which
|
||||
// would silently swallow a personal message during the brief
|
||||
// post-fresh-install window before the JS bridge dumps metadata.
|
||||
return new RoomMetadata(legacyName, true);
|
||||
// Legacy shape predates the encryption flag, so assume
|
||||
// encrypted=true (no reply action) to err on the side of
|
||||
// privacy.
|
||||
return new RoomMetadata(legacyName, true, true);
|
||||
} catch (Throwable t) {
|
||||
return new RoomMetadata("", true);
|
||||
return new RoomMetadata("", true, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -893,7 +893,10 @@
|
|||
"channel_group_room": "Group chats",
|
||||
"channel_group_room_description": "New messages from group chats and channels",
|
||||
"self_name": "You",
|
||||
"action_mark_as_read": "Mark as read"
|
||||
"action_mark_as_read": "Mark as read",
|
||||
"action_reply": "Reply",
|
||||
"reply_hint": "Reply…",
|
||||
"reply_failed": "Could not send your reply"
|
||||
},
|
||||
"Bots": {
|
||||
"not_connected_title": "{{name}} is not connected",
|
||||
|
|
|
|||
|
|
@ -909,7 +909,10 @@
|
|||
"channel_group_room": "Групповые чаты",
|
||||
"channel_group_room_description": "Новые сообщения из групповых чатов и каналов",
|
||||
"self_name": "Я",
|
||||
"action_mark_as_read": "Прочитано"
|
||||
"action_mark_as_read": "Прочитано",
|
||||
"action_reply": "Ответить",
|
||||
"reply_hint": "Ответ…",
|
||||
"reply_failed": "Не удалось отправить ответ"
|
||||
},
|
||||
"Bots": {
|
||||
"not_connected_title": "{{name}} не подключён",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ const ANDROID_KEYS = [
|
|||
'channel_group_room_description',
|
||||
'self_name',
|
||||
'action_mark_as_read',
|
||||
'action_reply',
|
||||
'reply_hint',
|
||||
'reply_failed',
|
||||
];
|
||||
|
||||
// i18next uses named placeholders ({{inviter}}); Android string resources
|
||||
|
|
|
|||
|
|
@ -51,9 +51,15 @@ const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => {
|
|||
return mx.getRooms().reduce<RoomMetadataMap>((acc, room) => {
|
||||
const { name } = room;
|
||||
if (typeof name !== 'string' || name.length === 0) return acc;
|
||||
// hasEncryptionStateEvent reads the m.room.encryption state event from
|
||||
// the live timeline — synchronous and cheap. Used by the Java side to
|
||||
// gate the inline reply action: encrypted rooms get a read-only
|
||||
// notification because the Java path has no key material to encrypt
|
||||
// outgoing replies with.
|
||||
acc[room.roomId] = {
|
||||
name,
|
||||
isDirect: dmRooms.has(room.roomId),
|
||||
isEncrypted: room.hasEncryptionStateEvent(),
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
|
@ -418,7 +424,15 @@ export function usePushNotificationsLifecycle(): void {
|
|||
// Re-dump the room metadata snapshot whenever m.direct changes — without
|
||||
// this, freshly-created DMs would land on the louder group-message
|
||||
// channel until the next visibilitychange re-bridge. The dump is small
|
||||
// (room id + name + isDirect bool) and account-data updates are rare.
|
||||
// (room id + name + isDirect + isEncrypted) and account-data updates are
|
||||
// rare.
|
||||
//
|
||||
// The same effect also re-dumps on m.room.encryption state events so the
|
||||
// inline-reply action is dropped within seconds of a room being switched
|
||||
// to E2EE. Without this re-dump, a cleartext-by-prefs notification could
|
||||
// post the reply action onto a freshly-encrypted room and the receiver
|
||||
// would send a cleartext reply (Synapse does not enforce the
|
||||
// "encrypted-only" rule, so the leak is real).
|
||||
useEffect(() => {
|
||||
if (!isAndroidPlatform()) return undefined;
|
||||
if (!pushEnabled) return undefined;
|
||||
|
|
@ -427,10 +441,17 @@ export function usePushNotificationsLifecycle(): void {
|
|||
if (event.getType() !== AccountDataEvent.Direct) return;
|
||||
dumpRoomNamesToNative(mx).catch(noop);
|
||||
};
|
||||
const handleTimeline = (event: MatrixEvent) => {
|
||||
if (event.getType() === 'm.room.encryption') {
|
||||
dumpRoomNamesToNative(mx).catch(noop);
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
mx.on(RoomEvent.Timeline, handleTimeline);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||
};
|
||||
}, [mx, pushEnabled]);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@
|
|||
import { registerPlugin } from '@capacitor/core';
|
||||
import { isAndroidPlatform } from '../utils/capacitor';
|
||||
|
||||
export type RoomMetadataMap = Record<string, string | { name: string; isDirect: boolean }>;
|
||||
export type RoomMetadataMap = Record<
|
||||
string,
|
||||
string | { name: string; isDirect: boolean; isEncrypted: boolean }
|
||||
>;
|
||||
|
||||
interface PollingPluginIface {
|
||||
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue