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
|
<receiver
|
||||||
android:name=".NotificationDismissReceiver"
|
android:name=".NotificationDismissReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ReplyReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,18 @@ final class PushStrings {
|
||||||
return forAppLocale(ctx).getString(R.string.push_action_mark_as_read);
|
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
|
* Build the invite-notification body from inviter + room name, falling
|
||||||
* back through four variants when one or both are absent. The res IDs
|
* 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.NotificationCompat;
|
||||||
import androidx.core.app.Person;
|
import androidx.core.app.Person;
|
||||||
|
import androidx.core.app.RemoteInput;
|
||||||
|
|
||||||
import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
||||||
import com.google.firebase.messaging.RemoteMessage;
|
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 MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1";
|
||||||
private static final String CHANNEL_ID_DM = "vojo_messages_dm_v1";
|
private static final String CHANNEL_ID_DM = "vojo_messages_dm_v1";
|
||||||
private static final String CHANNEL_ID_GROUP = "vojo_messages_group_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";
|
private static final String GROUP_KEY = "vojo_messages";
|
||||||
// NotificationChannel settings (vibration pattern, sound, importance) are
|
// NotificationChannel settings (vibration pattern, sound, importance) are
|
||||||
|
|
@ -491,6 +496,15 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||||
.addAction(buildMarkAsReadAction(ctx, roomId, eventId));
|
.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
|
dlog("msg: posting notif id=" + notifId + " channel=" + channelId
|
||||||
+ " historySize=" + history.size()
|
+ " historySize=" + history.size()
|
||||||
+ " notifsEnabled=" + nm.areNotificationsEnabled());
|
+ " notifsEnabled=" + nm.areNotificationsEnabled());
|
||||||
|
|
@ -655,6 +669,137 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
.build();
|
.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) {
|
private static Person buildSelfPerson(Context ctx) {
|
||||||
SharedPreferences prefs = ctx.getSharedPreferences(
|
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
|
|
@ -712,36 +857,54 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
/**
|
/**
|
||||||
* Snapshot of per-room metadata bridged from JS via
|
* Snapshot of per-room metadata bridged from JS via
|
||||||
* PollingPlugin.saveRoomNames (which now accepts both a legacy
|
* 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 {
|
private static final class RoomMetadata {
|
||||||
final String name;
|
final String name;
|
||||||
final boolean isDirect;
|
final boolean isDirect;
|
||||||
|
final boolean isEncrypted;
|
||||||
|
|
||||||
RoomMetadata(String name, boolean isDirect) {
|
RoomMetadata(String name, boolean isDirect, boolean isEncrypted) {
|
||||||
this.name = name == null ? "" : name;
|
this.name = name == null ? "" : name;
|
||||||
this.isDirect = isDirect;
|
this.isDirect = isDirect;
|
||||||
|
this.isEncrypted = isEncrypted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) {
|
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(
|
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||||
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
|
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 {
|
try {
|
||||||
JSONObject map = new JSONObject(raw);
|
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:
|
// Legacy shape: { roomId: "Display name" }. New shape:
|
||||||
// { roomId: { name: "Display name", isDirect: bool } }. Parse
|
// { roomId: { name: "Display name", isDirect: bool,
|
||||||
// tolerantly so an APK that still has the old map written to
|
// isEncrypted: bool } }. Parse tolerantly so an APK
|
||||||
// prefs from a previous boot doesn't lose channel routing.
|
// that still has the old map written to prefs from a previous
|
||||||
|
// boot doesn't lose channel routing.
|
||||||
JSONObject obj = map.optJSONObject(roomId);
|
JSONObject obj = map.optJSONObject(roomId);
|
||||||
if (obj != null) {
|
if (obj != null) {
|
||||||
String name = obj.optString("name", "");
|
String name = obj.optString("name", "");
|
||||||
boolean isDirect = obj.optBoolean("isDirect", true);
|
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, "");
|
String legacyName = map.optString(roomId, "");
|
||||||
// Default to DM when we have no isDirect signal. DM is the
|
// 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
|
// bad failure than under-alerting on a misclassified DM, which
|
||||||
// would silently swallow a personal message during the brief
|
// would silently swallow a personal message during the brief
|
||||||
// post-fresh-install window before the JS bridge dumps metadata.
|
// 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) {
|
} catch (Throwable t) {
|
||||||
return new RoomMetadata("", true);
|
return new RoomMetadata("", true, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -893,7 +893,10 @@
|
||||||
"channel_group_room": "Group chats",
|
"channel_group_room": "Group chats",
|
||||||
"channel_group_room_description": "New messages from group chats and channels",
|
"channel_group_room_description": "New messages from group chats and channels",
|
||||||
"self_name": "You",
|
"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": {
|
"Bots": {
|
||||||
"not_connected_title": "{{name}} is not connected",
|
"not_connected_title": "{{name}} is not connected",
|
||||||
|
|
|
||||||
|
|
@ -909,7 +909,10 @@
|
||||||
"channel_group_room": "Групповые чаты",
|
"channel_group_room": "Групповые чаты",
|
||||||
"channel_group_room_description": "Новые сообщения из групповых чатов и каналов",
|
"channel_group_room_description": "Новые сообщения из групповых чатов и каналов",
|
||||||
"self_name": "Я",
|
"self_name": "Я",
|
||||||
"action_mark_as_read": "Прочитано"
|
"action_mark_as_read": "Прочитано",
|
||||||
|
"action_reply": "Ответить",
|
||||||
|
"reply_hint": "Ответ…",
|
||||||
|
"reply_failed": "Не удалось отправить ответ"
|
||||||
},
|
},
|
||||||
"Bots": {
|
"Bots": {
|
||||||
"not_connected_title": "{{name}} не подключён",
|
"not_connected_title": "{{name}} не подключён",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ const ANDROID_KEYS = [
|
||||||
'channel_group_room_description',
|
'channel_group_room_description',
|
||||||
'self_name',
|
'self_name',
|
||||||
'action_mark_as_read',
|
'action_mark_as_read',
|
||||||
|
'action_reply',
|
||||||
|
'reply_hint',
|
||||||
|
'reply_failed',
|
||||||
];
|
];
|
||||||
|
|
||||||
// i18next uses named placeholders ({{inviter}}); Android string resources
|
// i18next uses named placeholders ({{inviter}}); Android string resources
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,15 @@ const buildRoomMetadataSnapshot = (mx: MatrixClient): RoomMetadataMap => {
|
||||||
return mx.getRooms().reduce<RoomMetadataMap>((acc, room) => {
|
return mx.getRooms().reduce<RoomMetadataMap>((acc, room) => {
|
||||||
const { name } = room;
|
const { name } = room;
|
||||||
if (typeof name !== 'string' || name.length === 0) return acc;
|
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] = {
|
acc[room.roomId] = {
|
||||||
name,
|
name,
|
||||||
isDirect: dmRooms.has(room.roomId),
|
isDirect: dmRooms.has(room.roomId),
|
||||||
|
isEncrypted: room.hasEncryptionStateEvent(),
|
||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
@ -418,7 +424,15 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
// Re-dump the room metadata snapshot whenever m.direct changes — without
|
// Re-dump the room metadata snapshot whenever m.direct changes — without
|
||||||
// this, freshly-created DMs would land on the louder group-message
|
// this, freshly-created DMs would land on the louder group-message
|
||||||
// channel until the next visibilitychange re-bridge. The dump is small
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!isAndroidPlatform()) return undefined;
|
if (!isAndroidPlatform()) return undefined;
|
||||||
if (!pushEnabled) return undefined;
|
if (!pushEnabled) return undefined;
|
||||||
|
|
@ -427,10 +441,17 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
if (event.getType() !== AccountDataEvent.Direct) return;
|
if (event.getType() !== AccountDataEvent.Direct) return;
|
||||||
dumpRoomNamesToNative(mx).catch(noop);
|
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(ClientEvent.AccountData, handleAccountData);
|
||||||
|
mx.on(RoomEvent.Timeline, handleTimeline);
|
||||||
return () => {
|
return () => {
|
||||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||||
|
mx.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||||
};
|
};
|
||||||
}, [mx, pushEnabled]);
|
}, [mx, pushEnabled]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@
|
||||||
import { registerPlugin } from '@capacitor/core';
|
import { registerPlugin } from '@capacitor/core';
|
||||||
import { isAndroidPlatform } from '../utils/capacitor';
|
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 {
|
interface PollingPluginIface {
|
||||||
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>;
|
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue