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:
v.lagerev 2026-05-17 02:29:20 +03:00
parent ac572afb32
commit 75022b9331
9 changed files with 430 additions and 15 deletions

View file

@ -117,6 +117,10 @@
<receiver
android:name=".NotificationDismissReceiver"
android:exported="false" />
<receiver
android:name=".ReplyReceiver"
android:exported="false" />
</application>
<!-- Permissions -->

View file

@ -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

View 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;
}
}

View file

@ -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);
}
}

View file

@ -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",

View file

@ -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}} не подключён",

View file

@ -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

View file

@ -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]);

View file

@ -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>;