feat(push): add WorkManager polling fallback that delivers notifications via /_matrix/client/v3/notifications when FCM is blocked

This commit is contained in:
heaven 2026-05-17 01:27:55 +03:00
parent b9aad691b5
commit 408f165f60
16 changed files with 1454 additions and 54 deletions

View file

@ -87,6 +87,11 @@ dependencies {
// already depends on firebase-messaging but declares it `implementation`
// so classes aren't exposed at app-module compile time.
implementation "com.google.firebase:firebase-messaging:25.0.1"
// WorkManager hosts VojoPollWorker periodic /notifications poll that
// delivers messages and missed-call surfaces on networks where FCM
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
// in the merged manifest; we declare no permission for it.
implementation "androidx.work:work-runtime:2.10.0"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View file

@ -121,7 +121,14 @@ public class CallForegroundPlugin extends Plugin {
// extras Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
// on containsKey. Empty string also satisfies the gate; we pass the
// caller's value through verbatim.
VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
// Mark in NotificationDedup so a polling fire 15 minutes later
// doesn't post a "Missed call" notification for a ring the user
// already saw live via the in-app strip. Mirrors the FCM-arrival
// path in VojoFirebaseMessagingService.onMessageReceived.
if (seeded) {
NotificationDedup.markNotified(getContext(), eventId);
}
call.resolve();
}

View file

@ -64,6 +64,7 @@ public class MainActivity extends BridgeActivity {
registerPlugin(CallForegroundPlugin.class);
registerPlugin(LaunchSplashPlugin.class);
registerPlugin(ShareTargetPlugin.class);
registerPlugin(PollingPlugin.class);
// AndroidX SplashScreen must be installed before super.onCreate().
// Keep it until the web splash confirms its first visible frame is

View file

@ -0,0 +1,104 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Cross-source LRU dedup for rendered push event_ids.
*
* Both the FCM service (after a successful nm.notify) and the polling Worker
* write into the same bounded SharedPreferences-backed set. The Worker reads
* it to skip events FCM already delivered which fixes the regression where
* a user who dismissed an FCM notification before polling fired would see
* the same event resurface up to 15 minutes later via the polling fallback.
*
* The native `eventId.hashCode()` notification-id slot is still the primary
* dedup for *concurrent* render (Android NotificationManager replace), but
* that only collapses surfaces while both notifications are still visible;
* once the user dismisses, the slot is empty and the second render would
* post fresh. This shared set covers that gap.
*
* Synchronisation: SharedPreferences read-modify-write is not atomic across
* threads/processes, and FCM service runs on a Firebase-managed background
* thread while the Worker runs on WorkManager's executor. We serialise all
* mutations through a static lock. Critical sections are short (string split
* + LinkedHashSet trim + putString) no Binder calls.
*/
final class NotificationDedup {
// Capacity is intentionally larger than VojoPollWorker's worst-case per-run
// event count (MAX_PAGES_PER_RUN × PAGE_LIMIT = 250). If a single fire
// marks 250 events and the cap were 200, the 50 oldest of those would
// already be evicted by the time we finish writing so a sibling poll
// resuming the same window would re-render them. 500 gives 2× headroom
// while staying ~12 KB in SharedPreferences (negligible).
private static final int MAX_TRACKED = 500;
private static final Object lock = new Object();
private NotificationDedup() {}
/** Returns true iff the given event_id has been notified in a recent cycle. */
static boolean wasNotified(Context ctx, String eventId) {
if (eventId == null || eventId.isEmpty()) return false;
synchronized (lock) {
return readSet(ctx).contains(eventId);
}
}
/** Append the event_id to the LRU set, trimming the oldest when full. */
static void markNotified(Context ctx, String eventId) {
if (eventId == null || eventId.isEmpty()) return;
synchronized (lock) {
Set<String> set = readSet(ctx);
// LinkedHashSet preserves insertion order re-adding moves to tail
// only if we remove-then-add. The Set#add no-op on a present entry
// does NOT refresh position, but the simple "drop oldest" trim
// below is adequate for our scale and matches the Worker's
// existing semantics. Skip the disk write entirely when add()
// returned false the event was already in the set, persistence
// would just churn SharedPreferences for no state change.
if (!set.add(eventId)) return;
if (set.size() > MAX_TRACKED) {
Iterator<String> it = set.iterator();
int drop = set.size() - MAX_TRACKED;
while (it.hasNext() && drop > 0) {
it.next();
it.remove();
drop -= 1;
}
}
writeSet(ctx, set);
}
}
/** Caller must hold {@link #lock}. */
private static Set<String> readSet(Context ctx) {
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
String raw = prefs.getString(VojoPollWorker.KEY_NOTIFIED_IDS, "");
Set<String> out = new LinkedHashSet<>();
if (raw.isEmpty()) return out;
for (String id : raw.split(",")) {
if (!id.isEmpty()) out.add(id);
}
return out;
}
/** Caller must hold {@link #lock}. */
private static void writeSet(Context ctx, Set<String> set) {
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
StringBuilder sb = new StringBuilder(set.size() * 25);
boolean first = true;
for (String id : set) {
if (!first) sb.append(',');
sb.append(id);
first = false;
}
prefs.edit().putString(VojoPollWorker.KEY_NOTIFIED_IDS, sb.toString()).apply();
}
}

View file

@ -0,0 +1,188 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.work.Constraints;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.concurrent.TimeUnit;
/**
* JS Android bridge for the WorkManager-based polling fallback.
*
* Lifecycle:
* - JS calls saveSession({accessToken, homeserverUrl, userId}) on login,
* on push (re)enable, and on visibilitychange visible (to recover a
* 401-cleared credentials slot without a full remount).
* - JS calls schedule({intervalMinutes}) once push is enabled. Idempotent:
* KEEP policy means a second schedule() call against an already-enqueued
* worker is a no-op (the running period continues unchanged).
* - JS calls saveRoomNames({names}) on mount + visibilitychange visible
* so VojoPollWorker has a local cache to resolve room_id display name
* without making N extra GET /rooms/{id}/state/m.room.name requests.
* Brand-new rooms created between visibility events fall back to
* sender_display_name in the renderer.
* - JS calls cancel() + clearSession() on logout / push disable.
*
* Worker tag: a single unique periodic worker named UNIQUE_WORK_NAME KEEP
* policy prevents schedule churn from re-creating it. Cancel() removes it
* by the same name.
*/
@CapacitorPlugin(name = "Polling")
public class PollingPlugin extends Plugin {
private static final String TAG = "PollingPlugin";
private static final String UNIQUE_WORK_NAME = "vojo_push_poll";
// Android's hard floor for PeriodicWorkRequest. Requests with shorter
// intervals are silently clamped to 15 minutes. We accept the requested
// value from JS but enforce the floor here so misuse from JS doesn't
// produce a silently-different behavior.
private static final long MIN_INTERVAL_MINUTES = 15;
@PluginMethod
public void saveSession(PluginCall call) {
String accessToken = call.getString("accessToken");
String homeserverUrl = call.getString("homeserverUrl");
if (accessToken == null || accessToken.isEmpty()
|| homeserverUrl == null || homeserverUrl.isEmpty()) {
call.reject("missing_accessToken_or_homeserverUrl");
return;
}
String userId = call.getString("userId");
SharedPreferences prefs = getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit()
.putString(VojoPollWorker.KEY_ACCESS_TOKEN, accessToken)
.putString(VojoPollWorker.KEY_HOMESERVER_URL, homeserverUrl);
if (userId != null && !userId.isEmpty()) {
editor.putString(VojoPollWorker.KEY_USER_ID, userId);
}
// Seed the watermark to "now minus a small clock-skew buffer" on the
// first saveSession after install / logout. Without seeding the
// Worker's first fire sees watermark=0 and renders every historical
// unread /notifications entry as a fresh push. The buffer covers the
// case where the device clock runs ahead of the homeserver's clock
// event ts is server-side, so a too-fresh local seed would silently
// skip recently-arrived events as "older than watermark" forever.
// 60s tolerates typical NTP drift while still suppressing days-old
// backlog on first enable. We seed only when the key is absent so
// subsequent saveSession calls (token rotation, visibilitychange
// re-bridge) don't reset live state.
if (!prefs.contains(VojoPollWorker.KEY_LAST_SEEN_TS)) {
editor.putLong(
VojoPollWorker.KEY_LAST_SEEN_TS,
System.currentTimeMillis() - SEED_CLOCK_SKEW_BUFFER_MS
);
}
editor.apply();
call.resolve();
}
private static final long SEED_CLOCK_SKEW_BUFFER_MS = 60_000L;
@PluginMethod
public void clearSession(PluginCall call) {
getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
.edit()
.remove(VojoPollWorker.KEY_ACCESS_TOKEN)
.remove(VojoPollWorker.KEY_HOMESERVER_URL)
.remove(VojoPollWorker.KEY_USER_ID)
.remove(VojoPollWorker.KEY_LAST_SEEN_TS)
.remove(VojoPollWorker.KEY_DRAIN_CURSOR)
.remove(VojoPollWorker.KEY_DRAIN_TARGET_TS)
.remove(VojoPollWorker.KEY_NOTIFIED_IDS)
.remove(VojoPollWorker.KEY_ROOM_NAMES)
.apply();
call.resolve();
}
@PluginMethod
public void saveRoomNames(PluginCall call) {
JSObject names = call.getObject("names");
if (names == null) {
// Empty map is also valid (user cleared all rooms) JS passes
// {} explicitly in that case; missing key is a contract bug.
call.reject("missing_names");
return;
}
// `JSObject extends JSONObject`, so names.toString() is already a
// valid JSON serialisation of validated values no need to re-parse
// it through `new JSONObject(...)` just to re-serialise. Persist
// verbatim.
getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
.edit()
.putString(VojoPollWorker.KEY_ROOM_NAMES, names.toString())
.apply();
call.resolve();
}
@PluginMethod
public void schedule(PluginCall call) {
Integer intervalMinutes = call.getInt("intervalMinutes", 15);
long interval = Math.max(MIN_INTERVAL_MINUTES, intervalMinutes != null ? intervalMinutes : 15);
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
PeriodicWorkRequest req = new PeriodicWorkRequest.Builder(
VojoPollWorker.class, interval, TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag("vojo_push_poll")
.build();
try {
WorkManager.getInstance(getContext())
.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
req
);
Log.d(TAG, "scheduled periodic poll every " + interval + " minutes");
call.resolve();
} catch (Throwable t) {
Log.w(TAG, "schedule failed", t);
call.reject("schedule_failed: " + t.getMessage());
}
}
@PluginMethod
public void cancel(PluginCall call) {
try {
// Block on the Operation so callers awaiting cancel() see the
// cancel committed to WorkManager's database before we resolve.
// (NOTE: this does NOT interrupt a Worker that's already mid
// doWork(); cooperative cancellation via isStopped() is owned
// by VojoPollWorker itself.) Without this wait a fast
// disablereenable sequence races with ExistingPeriodicWorkPolicy.KEEP
// the second enqueueUniquePeriodicWork can land before the
// cancel is committed and become a no-op. We're already off
// the main thread (Capacitor dispatches plugin calls on its
// own executor), so the blocking get() is safe here.
WorkManager.getInstance(getContext())
.cancelUniqueWork(UNIQUE_WORK_NAME)
.getResult()
.get();
Log.d(TAG, "cancelled periodic poll");
call.resolve();
} catch (Throwable t) {
Log.w(TAG, "cancel failed", t);
call.reject("cancel_failed: " + t.getMessage());
}
}
}

View file

@ -45,6 +45,15 @@ final class PushStrings {
return forAppLocale(ctx).getString(R.string.push_invitation);
}
static String missedCallTitle(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_missed_call);
}
static String missedCallBody(Context ctx, String caller) {
String safeCaller = caller == null ? "" : caller;
return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller);
}
/**
* 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

@ -32,7 +32,11 @@ import java.util.concurrent.ConcurrentHashMap;
* but JS listeners detach when the WebView is paused/backgrounded.
*
* Message branch: builds a system notification when the activity is NOT in
* the foreground covering both "backgrounded" and "killed" cases.
* the foreground covering both "backgrounded" and "killed" cases. The
* actual render is delegated to the static `renderMessageNotification`
* helper below, which is also called from VojoPollWorker on the FCM-blocked
* fallback path. Successful renders are recorded in NotificationDedup so
* the polling Worker doesn't re-surface them on a later cycle.
*
* Call branch: funnels every observed DM ring through the native ring
* registry (see below). FCM arrival either seeds the registry (foreground,
@ -187,6 +191,14 @@ public class VojoFirebaseMessagingService extends MessagingService {
dlog("route: call tombstoned, skipping native (event=" + eventId + ")");
return;
}
// Cross-source dedup at seed time, regardless of fg/bg branch.
// The bg path also marks again via postIncomingCallNotification
// after a successful nm.notify (defense in depth markNotified
// is idempotent). The fg path otherwise wouldn't mark at all,
// and a polling fire 15 minutes later would resurface the
// event as a "Missed call" notification even though the user
// already saw the live JS strip and chose to ignore it.
NotificationDedup.markNotified(this, eventId);
if (MainActivity.isInForeground) {
dlog("route: call seeded (foreground, JS strip owns UX) event=" + eventId);
// Race guard: MainActivity.onPause may have run its render
@ -212,11 +224,28 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
return;
}
String eventId = data.get("event_id");
if (!MainActivity.isInForeground) {
dlog("route: message-branch (background)");
showSystemNotification(remoteMessage);
boolean posted = renderMessageNotification(
this, data, remoteMessage.getMessageId());
// Cross-source dedup: only mark on a successful nm.notify so
// a permission-revoked SecurityException doesn't silently
// hide the event from VojoPollWorker's retry path. The
// polling Worker writes to the same store from doWork().
if (posted && eventId != null && !eventId.isEmpty()) {
NotificationDedup.markNotified(this, eventId);
}
} else {
dlog("route: skip (foreground, non-call)");
// Even though we didn't render, the JS timeline already
// surfaced the event live. Mark it in NotificationDedup so a
// later poll cycle fired after the user backgrounds the
// app but before the server marks the event read does not
// resurface it in the shade as a stale "missed" notification.
if (eventId != null && !eventId.isEmpty()) {
NotificationDedup.markNotified(this, eventId);
}
}
} catch (Throwable t) {
// Don't let any notification-construction bug crash the FCM service if we
@ -227,13 +256,39 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
}
private void showSystemNotification(RemoteMessage message) {
Map<String, String> data = message.getData();
/**
* Shared message/invite renderer. Called by the FCM service (instance path,
* background only) and by VojoPollWorker (background polling path, used as
* the FCM-blocked fallback delivery channel).
*
* Static + Context-parameterised so the Worker which has no Service
* lifetime can post into the same notification id space. Identity is
* derived as `(eventId ?? roomId ?? "vojo").hashCode()` (see the
* `uniqueKey` computation below); in normal operation both code paths
* always carry event_id, so the slot collapses an FCM-then-polling
* double-delivery while both surfaces are still visible. Once dismissed
* the slot is empty and Android wouldn't collapse anymore that gap is
* covered by NotificationDedup, the shared cross-source LRU written
* from both paths after a successful nm.notify.
*
* Both call sites pre-gate the "should I render" decision: FCM gates on
* `!isInForeground` (foreground hands UX to the live timeline), polling
* gates on its own watermark + NotificationDedup. This method just
* renders.
*/
static boolean renderMessageNotification(
Context ctx,
Map<String, String> data,
String messageId
) {
String roomId = data.get("room_id");
String eventId = data.get("event_id");
// Sygnal flattens nested notification fields with `_` separator:
// sender_display_name, content_body, content_msgtype, etc.
// sender_display_name, content_body, content_msgtype, etc. The polling
// fallback (VojoPollWorker) builds the same flattened shape when it
// parses /_matrix/client/v3/notifications responses, so the rest of
// this method is source-agnostic.
boolean isInvite = "m.room.member".equals(data.get("type"))
&& "invite".equals(data.get("content_membership"));
@ -244,26 +299,31 @@ public class VojoFirebaseMessagingService extends MessagingService {
// path would show "New message" and drop the invite semantic.
// Title marks the category ("Invitation"); inviter + room land
// in body (WhatsApp/Telegram convention; keeps shade scannable).
title = PushStrings.inviteTitle(this);
body = PushStrings.inviteBody(this, humanInviter(data), data.get("room_name"));
title = PushStrings.inviteTitle(ctx);
body = PushStrings.inviteBody(ctx, humanInviter(data), data.get("room_name"));
} else {
// For the polling fallback, sender_display_name is never present
// (the /notifications endpoint returns the raw event with no
// server-side profile resolution). The chain therefore lands on
// `sender` for fresh rooms strip the MXID to its local-part so
// the title reads "alice" instead of "@alice:hs.tld". Mirrors
// humanInviter() below.
title = firstNonEmpty(
data.get("room_name"),
data.get("sender_display_name"),
data.get("sender"),
mxidLocalPart(data.get("sender")),
"Vojo"
);
body = firstNonEmpty(
data.get("content_body"),
PushStrings.messageFallback(this)
PushStrings.messageFallback(ctx)
);
}
// Reuse Capacitor plugin's intent shape so its handleOnNewIntent() fires
// `pushNotificationActionPerformed` and the existing JS listener navigates.
Intent launchIntent = new Intent(this, MainActivity.class);
Intent launchIntent = new Intent(ctx, MainActivity.class);
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
String messageId = message.getMessageId();
launchIntent.putExtra("google.message_id", messageId != null ? messageId : "");
for (Map.Entry<String, String> e : data.entrySet()) {
launchIntent.putExtra(e.getKey(), e.getValue());
@ -275,9 +335,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent pendingIntent = PendingIntent.getActivity(this, requestCode, launchIntent, flags);
PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
@ -288,13 +348,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE);
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) {
Log.w(TAG, "msg: NotificationManager is null, abort");
return;
return false;
}
ensureMessageChannel(nm);
ensureMessageChannel(ctx, nm);
// Unique notification id per event each message shows separately in the shade.
// Guard against the (rare) hashCode collision with the reserved summary id.
@ -302,21 +362,111 @@ public class VojoFirebaseMessagingService extends MessagingService {
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
dlog("msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID
+ " notifsEnabled=" + nm.areNotificationsEnabled());
boolean posted = false;
try {
nm.notify(notifId, builder.build());
posted = true;
} catch (SecurityException e) {
Log.e(TAG, "msg: nm.notify threw SecurityException", e);
}
// Summary notification for the group (Android shows this when 4+ notifications stack)
NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID)
// Summary notification for the group (Android shows this when 4+ notifications stack).
// Wrapped in try/catch matching the main notify above so a permission-revoked
// SecurityException here does not propagate up into VojoPollWorker.doWork's
// Throwable catch and trigger an unnecessary Result.retry() loop.
try {
NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Vojo")
.setContentText(PushStrings.messagesFallback(ctx))
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true);
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
} catch (SecurityException e) {
Log.e(TAG, "msg: summary notify threw SecurityException", e);
}
return posted;
}
/**
* Missed-call notification renderer for polling fallback delivery. By the
* time VojoPollWorker observes an `m.rtc.notification` ring event (15-min
* cadence), the 30-second ring lifetime is always over rendering a live
* CallStyle would phantom-ring a long-dead call. Instead we post a regular
* notification "Пропущенный звонок от X" so the user knows somebody tried
* to call.
*
* Notification id space is shared with renderMessageNotification (same
* `eventId.hashCode()` slot), so if FCM later catches up and delivers the
* same ring event, Android replaces in place.
*/
static boolean renderMissedCallNotification(Context ctx, Map<String, String> data) {
String roomId = data.get("room_id");
String eventId = data.get("event_id");
if (roomId == null || eventId == null) {
Log.w(TAG, "missed-call: missing roomId/eventId, abort");
return false;
}
NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) {
Log.w(TAG, "missed-call: NotificationManager is null, abort");
return false;
}
// Reuse the message channel missed-call surfaces as a regular shade
// entry, not a live ring. Routing it to vojo_calls_v2 would inherit
// the bypass-DnD + ringtone + 20-pulse vibration channel settings,
// which is wrong for a stale post-facto miss.
ensureMessageChannel(ctx, nm);
String callerName = firstNonEmpty(
data.get("sender_display_name"),
data.get("room_name"),
mxidLocalPart(data.get("sender")),
"Vojo"
);
String title = PushStrings.missedCallTitle(ctx);
String body = PushStrings.missedCallBody(ctx, callerName);
Intent launchIntent = new Intent(ctx, MainActivity.class);
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
// Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
// on `google.message_id` existence; empty string satisfies the gate.
launchIntent.putExtra("google.message_id", "");
launchIntent.putExtra("room_id", roomId);
// Intentionally NO `notif_event_id` extra that's the call-tap signal
// for the live-call routing branch in usePushNotifications (Answer /
// Decline / FSI). Missed calls just open the room.
int requestCode = eventId.hashCode();
int flags = PendingIntent.FLAG_UPDATE_CURRENT
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags);
int notifId = eventId.hashCode();
if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1;
NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Vojo")
.setContentText(PushStrings.messagesFallback(this))
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true);
nm.notify(SUMMARY_NOTIFICATION_ID, summary.build());
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MISSED_CALL);
dlog("missed-call: posting notif id=" + notifId + " caller=" + callerName);
boolean posted = false;
try {
nm.notify(notifId, builder.build());
posted = true;
} catch (SecurityException e) {
Log.e(TAG, "missed-call: nm.notify threw SecurityException", e);
}
return posted;
}
//
@ -714,7 +864,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
String callerName = firstNonEmpty(
data.get("sender_display_name"),
data.get("room_name"),
data.get("sender"),
mxidLocalPart(data.get("sender")),
"Vojo"
);
String tag = "call_" + roomId;
@ -785,6 +935,17 @@ public class VojoFirebaseMessagingService extends MessagingService {
return false;
}
// Cross-source dedup: mark the ring event in NotificationDedup so the
// polling Worker if it later sees the same `m.rtc.notification` in
// /notifications does not post a "Missed call" notification for a
// ring that FCM already surfaced live (answered, declined, or
// expired). The CallStyle path uses a room-scoped tag/id slot, not
// eventId.hashCode(), so without this mark Android's NotificationManager
// replace would not collapse the two surfaces.
if (ringEventId != null && !ringEventId.isEmpty()) {
NotificationDedup.markNotified(ctx, ringEventId);
}
try {
scheduleCallNotificationExpiry(ctx, data, tag, notifId, fallbackBaseTs);
} catch (Throwable t) {
@ -841,8 +1002,9 @@ public class VojoFirebaseMessagingService extends MessagingService {
// from the service covers the fresh-install + killed-process race: FCM may
// deliver before the app has ever been launched (so the JS lifecycle effect
// never ran), in which case the channel doesn't exist yet and nm.notify
// would silently drop.
private void ensureMessageChannel(NotificationManager nm) {
// would silently drop. Same race covers VojoPollWorker Workers can fire
// before MainActivity ever runs after a reboot.
private static void ensureMessageChannel(Context ctx, NotificationManager nm) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
dlog("msg: creating channel " + CHANNEL_ID);
@ -964,13 +1126,29 @@ public class VojoFirebaseMessagingService extends MessagingService {
private static String humanInviter(Map<String, String> data) {
String displayName = data.get("sender_display_name");
if (displayName != null && !displayName.isEmpty()) return displayName;
String mxid = data.get("sender");
if (mxid == null || mxid.isEmpty()) return "";
if (mxid.startsWith("@")) {
int colon = mxid.indexOf(':');
if (colon > 1) return mxid.substring(1, colon);
return mxid.substring(1);
}
return mxid;
return mxidLocalPart(data.get("sender"));
}
// `@alice:hs.tld` `alice`. Returns null if the input doesn't look like
// a Matrix user id so callers can fall through to the next branch of
// their firstNonEmpty chain. Used by the polling fallback's title
// chain when the homeserver gave us only a raw sender MXID
// (/notifications has no Sygnal-side profile resolution).
//
// Edge cases:
// null / "" null
// "alice" "alice" (no @, return verbatim)
// "@alice:hs.tld" "alice"
// "@alice" "alice" (no colon, strip sigil only)
// "@:host" null (empty local-part is not usable)
// "@" null
private static String mxidLocalPart(String mxid) {
if (mxid == null || mxid.isEmpty()) return null;
if (mxid.charAt(0) != '@') return mxid;
int colon = mxid.indexOf(':');
if (colon > 1) return mxid.substring(1, colon);
if (colon == 1) return null;
if (mxid.length() > 1) return mxid.substring(1);
return null;
}
}

View file

@ -0,0 +1,607 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationManagerCompat;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
/**
* Periodic poll of `/_matrix/client/v3/notifications` as a fallback delivery
* channel for users whose network blocks FCM (mtalk.google.com:5228) the
* ~5% slice on whitelist intranets (corporate / school / government) that
* otherwise receive zero pushes.
*
* Scheduling: enqueued from PollingPlugin.schedule() with a 15-minute period
* (Android's minimum for PeriodicWorkRequest) and CONNECTED network constraint.
* Cancelled via PollingPlugin.cancel() on logout / push disable.
*
* Credentials: read from SharedPreferences (saved by the JS side through
* PollingPlugin.saveSession). Vanilla Synapse (no MAS/OIDC) issues
* non-expiring access tokens; we do not implement refresh-token flow here.
* If a 401 ever occurs, doWork returns Result.success() the next foreground
* launch re-saves the credentials and polling resumes. Retrying with a stale
* token would just waste battery and amplify rate limits.
*
* Output: messages and invites route through VojoFirebaseMessagingService
* .renderMessageNotification (shared with FCM, same notif-id slots
* Android dedupes by replace). RTC ring events route through
* .renderMissedCallNotification (always stale by the time we poll 15-min
* cadence vs 30-second ring lifetime), so the user sees "Missed call" instead
* of a phantom incoming-call CallStyle for a long-dead ring.
*
* E2EE caveat: Synapse cannot decrypt event content, so for end-to-end
* encrypted rooms the response carries `content.algorithm`+`ciphertext`
* with no `body`. The renderer falls through to PushStrings.messageFallback
* (i18n "New message") with the room name as title same UX as the web
* Service Worker on encrypted pushes. By design no key access from the
* Worker.
*
* Dedup is two complementary mechanisms:
* 1) A per-poll high-watermark on the latest event ts we've notified.
* Stored as KEY_LAST_SEEN_TS; advances only after a successful render
* (or a foreground-skipped event the user already saw in-app). Worker
* stops walking within a run as soon as it hits ts strictly less than
* watermark newest-first ordering guarantees the rest are also
* older. Same-ts events fall through to the secondary filters because
* multiple events can share a millisecond.
* 2) NotificationDedup a shared cross-source bounded LRU written by
* every renderer (FCM service after successful nm.notify, this Worker
* after successful render, and the ring-upsert paths at seed time).
* Lets the Worker skip events FCM already delivered even after the
* user dismissed the FCM notification.
*
* Each fire starts from the HEAD of /notifications (no persistent
* pagination cursor the spec's `next_token` walks BACKWARDS into
* history, so a persisted cursor silently drifts off the new events the
* next poll should see; see matrix-js-sdk client.ts:5040 for the
* reference traversal pattern). When a single fire's backlog exceeds
* MAX_PAGES_PER_RUN pages the leftover next_token is saved as
* KEY_DRAIN_CURSOR (with the head ts snapshotted in KEY_DRAIN_TARGET_TS)
* and resumed on the next run, so big backlogs (>250 events) drain over
* consecutive polls without being clipped.
*/
public class VojoPollWorker extends Worker {
private static final String TAG = "VojoPoll";
static final String PREFS = "vojo_poll_state";
static final String KEY_ACCESS_TOKEN = "access_token";
static final String KEY_HOMESERVER_URL = "homeserver_url";
static final String KEY_USER_ID = "user_id";
// High-watermark on the latest event ts we've already notified about.
// Stored as a long-millis string. Replaces an earlier `last_from` cursor
// experiment that misunderstood /notifications pagination direction.
static final String KEY_LAST_SEEN_TS = "last_seen_ts";
// Continuation cursor used when a single run hits MAX_PAGES_PER_RUN before
// reaching the watermark. Persists the next_token across runs so a >250
// event backlog drains over consecutive polls instead of being clipped
// forever by the page cap. Cleared once we either reach the watermark or
// exhaust pagination on a single run.
static final String KEY_DRAIN_CURSOR = "drain_cursor";
// The "head ts" we recorded when entering drain mode. After drain
// completes the watermark is jumped to THIS value rather than the
// (older) max ts seen during drain otherwise the bounded LRU could
// evict events from the original head and let the next normal run
// re-render them. Set once on entering drain mode, untouched while
// draining, cleared when drain completes.
static final String KEY_DRAIN_TARGET_TS = "drain_target_ts";
static final String KEY_NOTIFIED_IDS = "notified_ids";
static final String KEY_ROOM_NAMES = "room_names";
private static final int HTTP_TIMEOUT_MS = 30_000;
// Cap pages-per-fire so an unexpectedly large backlog (server-side bug,
// first run after a long offline window) cannot loop until Android's
// 10-minute Worker kill timer fires. 5 pages × 50 events = up to 250
// events per cycle well above realistic 15-minute backlog for a single
// user. We also break as soon as we hit ts watermark, so most polls
// touch only a single page.
private static final int MAX_PAGES_PER_RUN = 5;
private static final int PAGE_LIMIT = 50;
private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification";
public VojoPollWorker(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
Context ctx = getApplicationContext();
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String token = prefs.getString(KEY_ACCESS_TOKEN, null);
String homeserver = prefs.getString(KEY_HOMESERVER_URL, null);
if (token == null || homeserver == null) {
// Not logged in (or JS hasn't bridged credentials yet). Return
// success so WorkManager keeps the periodic schedule alive
// we'll pick up the credentials on the next fire.
Log.i(TAG, "poll: no credentials, bail");
return Result.success();
}
// If POST_NOTIFICATIONS was revoked we'd fetch + parse + try to
// render and then watch every nm.notify fail with SecurityException
// which leaves the LRU/watermark unadvanced (correctly so for a
// transient failure) and re-runs the same loop every 15 minutes
// forever. Bail early to avoid burning battery on a permanent
// user choice. The next visibility re-bridge inside the JS app
// will pick up a re-granted permission.
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
Log.i(TAG, "poll: notifications disabled, bail");
return Result.success();
}
long watermark = prefs.getLong(KEY_LAST_SEEN_TS, 0L);
String drainCursor = prefs.getString(KEY_DRAIN_CURSOR, null);
long drainTargetTs = prefs.getLong(KEY_DRAIN_TARGET_TS, 0L);
boolean wasDraining = drainCursor != null;
Map<String, String> roomNames = loadRoomNamesMap(prefs);
// Mirror the FCM service's foreground gate: if the user is actively in
// the app, the live timeline owns the UX and a system notification for
// a backlog event would be both stale and visually noisy. We still
// consume state (LRU, watermark) so the same event doesn't surface
// when the user later backgrounds the app.
boolean inForeground = MainActivity.isInForeground;
Log.i(TAG, "poll: start fg=" + inForeground
+ " watermark=" + watermark
+ " draining=" + wasDraining);
int pagesFetched = 0;
int renderedCount = 0;
int skippedDedupCount = 0;
long highestTsSeen = watermark;
boolean reachedWatermark = false;
// The continuation cursor we'd save if this run is capped. Starts as
// the resumed drain cursor; advances with each successful page fetch
// so a transient mid-pagination error still preserves drain progress.
String pendingCursor = drainCursor;
boolean paginationExhausted = false;
try {
// Cursor strategy: drain cursor resumes from where a previous capped
// run stopped; otherwise we start from the HEAD. next_token from
// /notifications paginates BACKWARDS into history, so a stored
// cursor must be used as a drain-only continuation, NOT as an
// ongoing "since" mark (the latter would silently drift off new
// events). Within a single fire we stop as soon as ts < watermark
// (newest-first ordering means everything past that is covered).
String nextFrom = drainCursor;
for (int page = 0; page < MAX_PAGES_PER_RUN && !reachedWatermark; page += 1) {
// Cooperative cancellation. WorkManager.cancelUniqueWork (called
// from PollingPlugin.cancel during logout / push disable) only
// marks future scheduling it does NOT interrupt this thread.
// Without these checks the Worker keeps fetching pages, posting
// notifications, and (worst of all) running the final
// editor.apply() with stale state written AFTER clearSession
// wiped prefs leaking watermark / drain cursor from the
// logged-out account into the next login.
if (isStopped()) return Result.success();
JSONObject body = fetchNotifications(homeserver, token, nextFrom);
// fetchNotifications throws on every failure path; a null
// return is unreachable in current code. The early-break here
// is a defensive belt-and-suspenders keep paginationExhausted
// consistent so the drain-bookkeeping below clears the cursor
// instead of replaying the same empty page forever.
if (body == null) {
paginationExhausted = true;
pendingCursor = null;
break;
}
JSONArray notifications = body.optJSONArray("notifications");
if (notifications == null || notifications.length() == 0) {
// Server returned no entries for this page. Treat as
// end-of-pagination so a drain in progress can complete
// (otherwise pendingCursor would keep its old value and
// we'd re-fetch the same empty page next cycle forever).
paginationExhausted = true;
pendingCursor = null;
break;
}
for (int i = 0; i < notifications.length(); i += 1) {
if (isStopped()) return Result.success();
JSONObject entry = notifications.optJSONObject(i);
if (entry == null) continue;
String eventId = extractEventId(entry);
if (eventId == null) continue;
// ts gate: server returns newest-first, so once we hit
// ts STRICTLY less than the watermark we know the rest of
// the page (and every subsequent page) is already covered.
// Same-ts events fall through to the LRU/read filters
// below multiple events can share a millisecond, and
// collapsing them at the ts boundary would silently drop
// a fresh sibling of a previously-rendered one.
long ts = entry.optLong("ts", 0L);
if (ts > 0 && ts < watermark) {
reachedWatermark = true;
break;
}
// Skip notifications the user already read on another
// client (web tab, Element, second device). Spec marks
// `read` as a required boolean on each entry.
if (entry.optBoolean("read", false)) {
if (ts > highestTsSeen) highestTsSeen = ts;
continue;
}
// Skip events the push rules said don't notify (muted
// rooms, dont_notify overrides). Without this gate
// polling would re-surface events Sygnal already
// suppressed for the FCM path the mute toggle
// wouldn't actually mute on whitelist networks.
if (!notifyAllowed(entry)) {
if (ts > highestTsSeen) highestTsSeen = ts;
continue;
}
// Cross-source dedup via NotificationDedup: FCM writes
// into this set after every successful render, so the
// Worker correctly skips events the FCM service already
// delivered even if the user dismissed the FCM
// notification before this cycle fired.
if (NotificationDedup.wasNotified(ctx, eventId)) {
skippedDedupCount += 1;
if (ts > highestTsSeen) highestTsSeen = ts;
continue;
}
// Three outcomes for marking + watermark advance:
// foreground mark + advance (skip render
// but consume state, otherwise
// next bg poll would replay)
// background + posted mark + advance
// background + !posted DON'T mark, DON'T advance
// (transient render failure
// should be retried next poll)
boolean posted = false;
boolean treatAsNotRenderable = false;
if (!inForeground) {
Map<String, String> flattened = flattenNotification(entry, roomNames);
String type = flattened.get("type");
boolean isRtcType = RTC_NOTIFICATION_TYPE.equals(type)
|| RTC_NOTIFICATION_TYPE_STABLE.equals(type);
boolean isRing = "ring".equals(flattened.get("content_notification_type"));
if (isRtcType && isRing) {
// Stale ring (call lifetime is 30 seconds; we poll
// every 15 minutes). Show "Missed call" so the user
// knows somebody tried, without phantom-ringing a
// long-dead call via CallStyle.
posted = VojoFirebaseMessagingService
.renderMissedCallNotification(ctx, flattened);
} else if (isRtcType) {
// Non-ring RTC sub-type. MSC4075 defines at least
// "ring" and "notification" the latter is the
// chat-style alert variant which doesn't make
// sense to surface as a stale "missed" entry from
// a 15-minute poll. Falling through to
// renderMessageNotification would post a generic
// "New message" with no body (no content.body on
// RTC events). Skip rendering but still mark seen
// so we don't re-walk it next poll.
treatAsNotRenderable = true;
} else {
posted = VojoFirebaseMessagingService
.renderMessageNotification(ctx, flattened, null);
}
}
// Mark + advance ts whenever we've consumed the event
// (foreground-skipped, non-ring-RTC skipped, or
// successfully rendered). Render-failure (bg branch where
// posted==false) is intentionally excluded so the next
// poll retries it.
if (inForeground || posted || treatAsNotRenderable) {
NotificationDedup.markNotified(ctx, eventId);
if (ts > highestTsSeen) highestTsSeen = ts;
if (posted) renderedCount += 1;
}
}
pagesFetched += 1;
// optString returns the fallback only when the key is absent;
// a literal JSON `null` becomes the string "null" guard
// against the rare server quirk so we don't loop on it.
String rawNext = body.optString("next_token", null);
if (rawNext == null || rawNext.isEmpty() || "null".equals(rawNext)) {
nextFrom = null;
} else {
nextFrom = rawNext;
}
pendingCursor = nextFrom;
if (nextFrom == null) {
paginationExhausted = true;
break;
}
}
} catch (UnauthorizedException e) {
Log.w(TAG, "poll: 401 — clearing credentials, awaiting next foreground re-bridge");
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.apply();
return Result.success();
} catch (ForbiddenException e) {
// 403 from Synapse is usually rate-limit or a transient server
// policy reject, not a dead token. Don't clear credentials
// just let the next periodic fire retry. Avoid Result.retry()
// because we don't want an immediate accelerated retry that
// amplifies the rate-limit cause.
Log.w(TAG, "poll: 403/429 — skipping this cycle, will retry on next scheduled fire");
return Result.success();
} catch (Throwable t) {
Log.w(TAG, "poll: failed at page " + pagesFetched, t);
return Result.retry();
}
// Final stopped-check before persisting state. If cancellation landed
// between the last in-loop check and here, do NOT apply: the
// accumulated editor writes would otherwise overwrite KEY_LAST_SEEN_TS
// and KEY_DRAIN_CURSOR AFTER JS clearSession wiped them, leaking
// stale state from the just-logged-out account into the next login.
if (isStopped()) return Result.success();
SharedPreferences.Editor editor = prefs.edit();
// Drain-mode bookkeeping. Three transitions:
// - normal normal (cap not hit): advance watermark to highestTsSeen.
// - normal drain (cap hit, no prior drain): save continuation
// cursor AND snapshot drainTargetTs = highestTsSeen. The current
// run's highest ts becomes the "fast-forward" target for when
// drain eventually completes without this, the bounded LRU
// could evict the original head events and let the post-drain
// normal run re-render them.
// - drain drain (still capped): keep cursor + target unchanged.
// Don't overwrite drainTargetTs with this run's highestTsSeen,
// because drain pages are always OLDER than the original head.
// - drain normal (drain complete): clear cursor + target. Advance
// watermark to drainTargetTs drain pages always walk backwards
// (older than the snapshotted head), so highestTsSeen accumulated
// during drain is by construction drainTargetTs.
boolean cappedWithMore = !reachedWatermark && !paginationExhausted && pendingCursor != null;
long newWatermark = watermark;
String drainState;
if (cappedWithMore) {
editor.putString(KEY_DRAIN_CURSOR, pendingCursor);
if (!wasDraining) {
// First run entering drain mode snapshot the head ts.
editor.putLong(KEY_DRAIN_TARGET_TS, highestTsSeen);
drainState = "drain-entered";
} else {
drainState = "drain-continued";
}
} else {
editor.remove(KEY_DRAIN_CURSOR);
editor.remove(KEY_DRAIN_TARGET_TS);
long advanceTo = wasDraining ? drainTargetTs : highestTsSeen;
if (advanceTo > watermark) {
editor.putLong(KEY_LAST_SEEN_TS, advanceTo);
newWatermark = advanceTo;
}
drainState = wasDraining ? "drain-exited" : "normal";
}
editor.apply();
Log.i(TAG, "poll: done pages=" + pagesFetched
+ " rendered=" + renderedCount
+ " dedupSkipped=" + skippedDedupCount
+ " watermark=" + newWatermark
+ " state=" + drainState);
return Result.success();
}
// Returns true iff at least one element of entry.actions is the literal
// string "notify". Per Matrix spec §13.13.1, tweak objects
// (`{set_tweak: ...}`) only MODIFY a notification produced by a separate
// `"notify"` action they do not by themselves imply notify. "dont_notify"
// or an empty actions array means the push rule explicitly suppressed
// this event (most commonly: a muted room).
private static boolean notifyAllowed(JSONObject entry) {
JSONArray actions = entry.optJSONArray("actions");
if (actions == null || actions.length() == 0) return false;
for (int i = 0; i < actions.length(); i += 1) {
Object a = actions.opt(i);
if ((a instanceof String) && "notify".equals(a)) return true;
}
return false;
}
//
// HTTP
//
private static final class UnauthorizedException extends IOException {
UnauthorizedException() {
super("401 Unauthorized");
}
}
// 403 from Synapse is most commonly a rate-limit or a transient policy
// reject (M_LIMIT_EXCEEDED, M_FORBIDDEN). It is NOT "token died" we
// surface it as a distinct exception so doWork can skip this cycle
// without clearing credentials and without an accelerated Result.retry()
// that would amplify the rate-limit cause.
private static final class ForbiddenException extends IOException {
ForbiddenException() {
super("403 Forbidden");
}
}
private JSONObject fetchNotifications(String homeserverUrl, String token, String fromCursor)
throws IOException {
StringBuilder url = new StringBuilder(homeserverUrl);
if (!homeserverUrl.endsWith("/")) url.append('/');
url.append("_matrix/client/v3/notifications?limit=").append(PAGE_LIMIT);
if (fromCursor != null && !fromCursor.isEmpty()) {
url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8"));
}
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
try {
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.setRequestProperty("Accept", "application/json");
// Identifiable UA so server logs can attribute polling traffic
// (some WAFs also flag bare "Java/<version>" as suspicious).
conn.setRequestProperty("User-Agent", "Vojo-Android-Poll/" + BuildConfig.VERSION_NAME);
conn.setConnectTimeout(HTTP_TIMEOUT_MS);
conn.setReadTimeout(HTTP_TIMEOUT_MS);
int code = conn.getResponseCode();
if (code == 401) throw new UnauthorizedException();
// Treat 429 (rate limited) and 403 (Synapse policy reject) the
// same: skip this cycle, don't retry-storm. Result.retry()'s 30s
// backoff would amplify the rate-limit cause; the next periodic
// fire in 15 minutes is well past any realistic Retry-After
// window from a Matrix homeserver.
if (code == 403 || code == 429) throw new ForbiddenException();
if (code < 200 || code >= 300) {
throw new IOException("HTTP " + code);
}
try (InputStream in = conn.getInputStream()) {
return new JSONObject(readAll(in));
} catch (org.json.JSONException je) {
throw new IOException("malformed JSON", je);
}
} finally {
conn.disconnect();
}
}
private static String readAll(InputStream in) throws IOException {
// Accumulate raw bytes, then decode the whole buffer as a single UTF-8
// string. Decoding each 8 KB chunk separately would corrupt multi-byte
// sequences that straddle a chunk boundary for a Russian-content
// notification body that crosses ~8 KB, the result is U+FFFD in place
// of a Cyrillic character. Also use != -1 rather than > 0 for the
// read loop: InputStream.read(byte[]) is contractually allowed to
// return 0 without indicating EOF.
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8 * 1024];
int n;
while ((n = in.read(buf)) != -1) {
if (n > 0) out.write(buf, 0, n);
}
return out.toString("UTF-8");
}
//
// Payload shaping
//
// The /notifications response shape is structured (event{type,sender,
// content{}}, room_id, ts, read, actions) different from Sygnal's
// flattened FCM payload. We flatten into the Sygnal-shape Map<String,
// String> so the shared renderer in VojoFirebaseMessagingService can
// stay source-agnostic. Keys we set: event_id, room_id, sender, type,
// content_membership, content_body, content_notification_type,
// content_sender_ts, content_lifetime, room_name (from local cache).
//
// NOTE: sender_display_name is NOT set here /notifications returns the
// raw event without the Sygnal-side profile resolution that gives FCM
// its `sender_display_name`. The renderer's title-fallback chain
// (room_name sender_display_name sender "Vojo") therefore lands
// on `sender` (a raw MXID) when the room name isn't cached. The renderer
// strips the MXID to its local-part as a final cosmetic guard so users
// see "alice" instead of "@alice:hs.tld".
//
private static Map<String, String> flattenNotification(
JSONObject entry, Map<String, String> roomNames
) {
Map<String, String> out = new HashMap<>();
String roomId = entry.optString("room_id", null);
if (roomId != null) out.put("room_id", roomId);
JSONObject event = entry.optJSONObject("event");
if (event != null) {
putIfPresent(out, event, "event_id", "event_id");
putIfPresent(out, event, "sender", "sender");
putIfPresent(out, event, "type", "type");
JSONObject content = event.optJSONObject("content");
if (content != null) {
putIfPresent(out, content, "membership", "content_membership");
putIfPresent(out, content, "body", "content_body");
putIfPresent(out, content, "notification_type", "content_notification_type");
if (content.has("sender_ts")) {
out.put("content_sender_ts", String.valueOf(content.optLong("sender_ts")));
}
if (content.has("lifetime")) {
out.put("content_lifetime", String.valueOf(content.optLong("lifetime")));
}
}
}
// Room name from the snapshot the JS side pushes through
// PollingPlugin.saveRoomNames, parsed once at the start of doWork().
// Brand-new rooms (not yet observed by JS at last bridge time) miss
// the cache the renderer falls back to sender / "Vojo".
if (roomId != null) {
String roomName = roomNames.get(roomId);
if (roomName != null && !roomName.isEmpty()) out.put("room_name", roomName);
}
return out;
}
// Parse the SharedPreferences-stored room-name JSON snapshot once per
// doWork() so we don't redo the parse for every event in the page (up to
// PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events).
private static Map<String, String> loadRoomNamesMap(SharedPreferences prefs) {
Map<String, String> out = new HashMap<>();
String raw = prefs.getString(KEY_ROOM_NAMES, null);
if (raw == null || raw.isEmpty()) return out;
try {
JSONObject map = new JSONObject(raw);
for (Iterator<String> it = map.keys(); it.hasNext(); ) {
String roomId = it.next();
if (map.isNull(roomId)) continue;
String name = map.optString(roomId, null);
if (name != null && !name.isEmpty()) out.put(roomId, name);
}
} catch (org.json.JSONException je) {
// Corrupt blob return empty map. Renderer falls back to sender.
}
return out;
}
private static void putIfPresent(
Map<String, String> out, JSONObject src, String srcKey, String dstKey
) {
// Guard against a literal JSON null at the key: JSONObject.optString
// returns the *fallback* only when the key is absent, but on a
// present-but-null key it coerces JSONObject.NULL to the four-char
// string "null", which would leak as "null" into a notification body.
if (!src.has(srcKey) || src.isNull(srcKey)) return;
String v = src.optString(srcKey, null);
if (v != null && !v.isEmpty()) out.put(dstKey, v);
}
private static String extractEventId(JSONObject entry) {
JSONObject event = entry.optJSONObject("event");
if (event == null) return null;
if (!event.has("event_id") || event.isNull("event_id")) return null;
String eventId = event.optString("event_id", null);
if (eventId == null || eventId.isEmpty()) return null;
return eventId;
}
}

View file

@ -54,6 +54,123 @@ Push notification text for Android is generated from `public/locales/{en,ru}.jso
The task requires `node` in `PATH`. Terminal builds and CI inherit it from the shell. **macOS Android Studio with nvm/fnm:** the GUI app may not see nvm-managed node. Workaround: set `NODE_BIN=/path/to/node` in `android/gradle.properties` (the task reads it via `project.findProperty('NODE_BIN')`) or launch AS from a shell that sources your node manager (`open -a "Android Studio"`).
## Push polling fallback (WorkManager)
Users on networks that block FCM (`mtalk.google.com:5228` — corporate, school
and government whitelist intranets, ~5% of our audience) get zero pushes from
the primary channel. To cover them we run a WorkManager periodic poll of
`/_matrix/client/v3/notifications` as a parallel best-effort delivery channel.
Always on whenever push is enabled — there's no smart-detect-and-switch (FCM
gives no client-visible delivery receipts; see
[push_unifiedpush_phase1.md §11](../plans/push_unifiedpush_phase1.md) for the
full rationale of why this is the only viable shape).
Components:
| Layer | File | Role |
|---|---|---|
| Worker | [`VojoPollWorker.java`](../../android/app/src/main/java/chat/vojo/app/VojoPollWorker.java) | Periodic fetch of `/notifications`, flattens response into Sygnal-shape `Map<String,String>`, routes message/invite → `renderMessageNotification`, RTC ring → `renderMissedCallNotification`. Skips events that are `read=true`, push-rule-suppressed (`actions` lacks `notify`), in NotificationDedup, or with `ts < watermark`. Foreground-gated: doesn't render system notifications while `MainActivity.isInForeground` (still consumes state). Saves a drain cursor when capped at `MAX_PAGES_PER_RUN`. |
| Bridge | [`PollingPlugin.java`](../../android/app/src/main/java/chat/vojo/app/PollingPlugin.java) | Capacitor plugin. JS calls `saveSession` (token + homeserver, seeds watermark on first use to skip historical backlog), `schedule(15)` (unique periodic worker), `saveRoomNames` (room-id → name cache), `cancel` (awaits WorkManager Operation completion) + `clearSession` on disable/logout. |
| Renderers | [`VojoFirebaseMessagingService.java::renderMessageNotification`, `::renderMissedCallNotification`](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) | Static, Context-parameterised so the Worker can post into the same notification id space as FCM. `eventId.hashCode()` slot — Android replaces in place when both paths deliver the same event AND both surfaces are still visible. After successful `nm.notify`, mark the event in NotificationDedup so the polling Worker doesn't re-surface it after the user dismisses an FCM-delivered one. |
| Dedup | [`NotificationDedup.java`](../../android/app/src/main/java/chat/vojo/app/NotificationDedup.java) | Thread-safe shared LRU set of rendered event_ids. Written by both FCM service (background renders AND foreground-skipped events) and Worker (after successful render or foreground-skip). Bounded at 500 entries to comfortably exceed a single Worker run's worst case (`MAX_PAGES_PER_RUN × PAGE_LIMIT = 250`), persisted in `vojo_poll_state` SharedPreferences. |
| JS plugin | [`src/app/plugins/polling.ts`](../../src/app/plugins/polling.ts) | `registerPlugin<PollingPluginIface>('Polling', { web: noop })`. Web has no analogue (SW already wakes for push) — fallback is a no-op. |
| Lifecycle | [`src/app/hooks/usePushNotifications.ts::usePushNotificationsLifecycle`](../../src/app/hooks/usePushNotifications.ts) | Reactive to `usePushEnabled()`. On mount with push enabled: `saveSession` + `schedule` + initial room-name dump. On `visibilitychange → visible`: re-`saveSession` (recovers a 401-cleared credentials slot without remount) + re-dump room names. On unmount or push disable: `cancel` + `clearSession`. |
Why polling is rendered as **missed call** (not CallStyle) for ring events: the
`m.rtc.notification` lifetime is 30 seconds; polling runs at the 15-minute
floor of `PeriodicWorkRequest`. Every ring observed by the Worker is already
stale and the live call long over — rendering CallStyle with ringtone would
phantom-ring a dead call. Missed-call style preserves the "you missed a call
from X" signal without the wrong UX. Live-call delivery for whitelist users
remains a gap; closing it requires a non-FCM live channel (UnifiedPush, see
the stale plan above).
Why we do not need a refresh-token flow: Vojo's homeserver is vanilla Synapse
without MAS/OIDC (see [server-side.md](server-side.md)), so access tokens are
long-lived. A 401 from the Worker logs out the credentials slot and waits for
the next foreground app launch to re-bridge — no native refresh-token logic
required. If we ever migrate to MAS, the Worker needs a refresh path.
Why our source manifest does not declare `RECEIVE_BOOT_COMPLETED`: WorkManager's
library manifest already declares the permission and the `RescheduleReceiver`,
which the manifest merger folds into the merged manifest. Reboot persistence
works end-to-end without our app re-declaring anything. Apps only need to add
the permission themselves when they listen for `BOOT_COMPLETED` for their own
purposes.
Edge cases handled:
- Token rotation (post-MAS migration): currently not bridged from JS to native
on token-rotate events. JS re-saves credentials on every lifecycle re-mount
AND on visibilitychange → visible, so user-driven re-open recovers within
seconds. After a 401 the Worker clears its credentials slot; after a 403
it leaves credentials alone and just skips the cycle (403 is most often a
transient rate-limit, not a dead token).
- First fire after install / re-login: `saveSession` seeds
`KEY_LAST_SEEN_TS` to `System.currentTimeMillis() - 60s` on first write,
so the Worker doesn't render every historical unread `/notifications`
entry as a fresh push. The 60s buffer tolerates device-clock drift ahead
of the homeserver (event `ts` is server-side); without it a fast-clock
device would silently skip fresh events as "older than watermark".
- POST_NOTIFICATIONS revoked at runtime: Worker bails early on
`NotificationManagerCompat.areNotificationsEnabled() == false`. Without
this guard `nm.notify` would throw `SecurityException` per event, leave
the LRU and watermark unadvanced, and re-walk the same backlog every 15
minutes until the user re-grants permission.
- Worker > 10 minutes (Android kill timer): bounded by `MAX_PAGES_PER_RUN=5`
× `PAGE_LIMIT=50` + 30s HTTP timeout per call. Cannot exceed ~3 minutes
in normal operation. Most polls touch only a single page because the ts
watermark short-circuits the loop.
- Large backlog (>250 events accumulated while offline): when a single fire
hits `MAX_PAGES_PER_RUN` before reaching the watermark, the Worker saves
the leftover `next_token` as `KEY_DRAIN_CURSOR` AND snapshots the head ts
of the first run as `KEY_DRAIN_TARGET_TS`. Subsequent fires resume from
that cursor instead of head; the target ts is the fast-forward
destination for the watermark when drain finally completes — without it,
the bounded LRU could evict head events and let the post-drain normal
run re-render them.
- Network unavailable: `NetworkType.CONNECTED` constraint skips the run; next
cycle retries.
- Doze: WorkManager honours maintenance windows. No catch-up — only the next
scheduled fire delivers the accumulated backlog. The Worker walks from the
head of `/notifications` and stops as soon as it reaches the watermark, so a
Doze-extended gap just produces a larger first-page walk.
- Pagination assumes newest-first ordering (Vojo runs vanilla Synapse, whose
`get_push_actions_for_user` issues `ORDER BY stream_ordering DESC`). The
Matrix spec for `/notifications` does not formally mandate this ordering, so
if Vojo ever migrates to a homeserver implementation that paginates oldest-
first (Conduit, Dendrite, …) the `ts < watermark` break would clip new
events. Revisit the Worker before any such migration.
- Already-read events (user read on another client) are skipped via the `read`
field on each `/notifications` entry; their ts still advances the watermark
so they don't get re-walked next poll.
- Muted rooms: `actions` array on each `/notifications` entry is consulted;
events without `notify` (i.e. `dont_notify` from a mute push rule) are
skipped. Without this, the mute toggle wouldn't actually mute polling-
delivered notifications even though Sygnal honours it for FCM.
- User in foreground: Worker doesn't render system notifications while
`MainActivity.isInForeground` (live timeline owns UX). State still
advances so events don't replay on the next backgrounded poll.
- FCM + polling double delivery: NotificationDedup is the single source of
truth — FCM service and Worker both write to it after successful render,
both read it before posting. Even if the user dismisses an FCM-delivered
notification before polling fires, the Worker skips it.
- UTF-8 multi-byte boundaries: `readAll` accumulates raw bytes and decodes
the full buffer once, never per-chunk; otherwise a Cyrillic character
straddling an 8 KB read boundary would become U+FFFD.
- Logout race: `initMatrix.ts::logoutClient`, `clearLocalSessionAndReload`,
and the `SessionLoggedOut` listener in `ClientRoot.tsx` all call
`polling.cancel()` + `polling.clearSession()` synchronously before
`window.location.replace`, so the Worker can't fire one more time with
the stale access_token. `cancel()` awaits the WorkManager `Operation` so
a fast disable → re-enable cycle doesn't race the `KEEP` policy. The
lifecycle effect's unmount cleanup repeats the same calls as
belt-and-suspenders.
Cleanups invoked symmetrically across every logout path:
`useDisablePushNotifications`, `logoutClient`, `clearLocalSessionAndReload`,
the `SessionLoggedOut` listener, and the lifecycle effect's unmount all
call `polling.cancel()` + `polling.clearSession()`.
## ADB wireless workflow
1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.

View file

@ -884,7 +884,9 @@
"invite_body": "{{inviter}} invited you to {{roomName}}",
"invite_body_no_room": "{{inviter}} invited you to a room",
"invite_body_no_inviter": "Invited you to {{roomName}}",
"invite_body_generic": "New invitation"
"invite_body_generic": "New invitation",
"missed_call": "Missed call",
"missed_call_body": "{{caller}} tried to call you"
},
"Bots": {
"not_connected_title": "{{name}} is not connected",

View file

@ -900,7 +900,9 @@
"invite_body": "{{inviter}} приглашает вас в {{roomName}}",
"invite_body_no_room": "{{inviter}} приглашает вас в комнату",
"invite_body_no_inviter": "Приглашение в {{roomName}}",
"invite_body_generic": "Новое приглашение"
"invite_body_generic": "Новое приглашение",
"missed_call": "Пропущенный звонок",
"missed_call_body": "{{caller}} пытался вам дозвониться"
},
"Bots": {
"not_connected_title": "{{name}} не подключён",

View file

@ -51,6 +51,8 @@ const ANDROID_KEYS = [
'invite_body_no_room',
'invite_body_no_inviter',
'invite_body_generic',
'missed_call',
'missed_call_body',
];
// i18next uses named placeholders ({{inviter}}); Android string resources
@ -59,9 +61,13 @@ const ANDROID_KEYS = [
// inviter, roomName) always passes inviter in position 1, roomName in
// position 2, regardless of how the translators order them in the JSON.
// Adding a new placeholder: add it here AND update PushStrings accordingly.
// `caller` reuses position 1: it only appears in missed_call_body, which
// has no other placeholders, so the position assignment is keyed per-key
// in practice — the table just enumerates every placeholder name we accept.
const PLACEHOLDER_POSITIONS = {
inviter: 1,
roomName: 2,
caller: 1,
};
const LANGS = {
@ -115,7 +121,7 @@ function verifyParity(bundles) {
const locales = Object.keys(bundles);
const [first, ...rest] = locales;
const firstKeys = new Set(Object.keys(bundles[first]));
for (const locale of rest) {
rest.forEach((locale) => {
const keys = new Set(Object.keys(bundles[locale]));
const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
@ -126,13 +132,13 @@ function verifyParity(bundles) {
` Extra in ${locale}: ${JSON.stringify(extraInOther)}`
);
}
}
for (const key of ANDROID_KEYS) {
for (const locale of locales) {
});
ANDROID_KEYS.forEach((key) => {
locales.forEach((locale) => {
if (typeof bundles[locale][key] !== 'string') {
throw new Error(`Push.${key} missing or non-string in ${locale}.json`);
}
}
});
// Placeholder tokens must match across locales for any given key —
// a translator adding {{user}} on one side silently produces
// literal-curly-brace output on the other surface.
@ -146,7 +152,7 @@ function verifyParity(bundles) {
return { locale, tokens };
});
const baseline = tokenSets[0];
for (const entry of tokenSets.slice(1)) {
tokenSets.slice(1).forEach((entry) => {
const baselineArr = [...baseline.tokens].sort();
const entryArr = [...entry.tokens].sort();
if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
@ -156,8 +162,8 @@ function verifyParity(bundles) {
`${entry.locale}=${JSON.stringify(entryArr)}`
);
}
}
}
});
});
}
function emitResource(locale, bundle, resDir) {
@ -170,12 +176,12 @@ function emitResource(locale, bundle, resDir) {
'-->',
'<resources>',
];
for (const key of ANDROID_KEYS) {
ANDROID_KEYS.forEach((key) => {
const raw = bundle[key];
const { text, placeholders } = convertPlaceholders(raw, locale, key);
const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
lines.push(` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
}
});
lines.push('</resources>');
lines.push('');
const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
@ -191,15 +197,15 @@ function main() {
}
const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;
const bundles = {};
for (const locale of Object.keys(LANGS)) {
bundles[locale] = readBundle(locale);
}
const bundles = Object.keys(LANGS).reduce((acc, locale) => {
acc[locale] = readBundle(locale);
return acc;
}, {});
verifyParity(bundles);
for (const locale of Object.keys(LANGS)) {
Object.keys(LANGS).forEach((locale) => {
const outPath = emitResource(locale, bundles[locale], resDir);
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
}
});
}
try {

View file

@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSetAtom } from 'jotai';
import type { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useClientConfig } from './useClientConfig';
import { isNativePlatform } from '../utils/capacitor';
import { isAndroidPlatform, isNativePlatform } from '../utils/capacitor';
import {
PUSH_APP_IDS,
PUSH_ENABLED_KEY,
@ -23,9 +24,23 @@ import {
import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils';
import { pendingCallActionAtom } from '../state/pendingCallAction';
import { useRoomNavigate } from './useRoomNavigate';
import { polling } from '../plugins/polling';
const noop = (): void => undefined;
const dumpRoomNamesToNative = async (mx: MatrixClient): Promise<void> => {
const names: Record<string, string> = mx
.getRooms()
.reduce<Record<string, string>>((acc, room) => {
const { name } = room;
if (typeof name === 'string' && name.length > 0) {
acc[room.roomId] = name;
}
return acc;
}, {});
await polling.saveRoomNames(names);
};
export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied';
/**
@ -259,6 +274,13 @@ export function useDisablePushNotifications(): () => Promise<void> {
// user explicitly turned push off on this device.
await removeRtcRingPushRule(mx);
// 4. Tear down the WorkManager polling fallback. Belt-and-suspenders with
// the lifecycle effect's cleanup — without this, polling could keep
// firing against a stale access_token between disable and the next
// re-render that observes the new pushEnabled state.
await polling.cancel();
await polling.clearSession();
clearPusherIds();
setPushEnabled(false);
}, [mx]);
@ -277,6 +299,7 @@ export function usePushNotificationsLifecycle(): void {
const navigate = useNavigate();
const { navigateRoom } = useRoomNavigate();
const setPendingCallAction = useSetAtom(pendingCallActionAtom);
const pushEnabled = usePushEnabled();
useEffect(() => {
if (isNativePlatform()) return;
@ -327,6 +350,77 @@ export function usePushNotificationsLifecycle(): void {
};
}, [navigate, navigateRoom, register]);
// WorkManager-based polling fallback for users where FCM is blocked.
// Runs in parallel with FCM — the renderer dedupes by event_id.hashCode()
// notification id, so a double-delivery (FCM in seconds + polling at the
// next 15-min cycle) collapses to one entry in the shade. Web has no
// equivalent; the Service Worker already handles all push wakeups.
useEffect(() => {
if (!isAndroidPlatform()) return undefined;
if (!pushEnabled) return undefined;
const token = mx.getAccessToken();
const homeserverUrl = mx.baseUrl;
if (!token || !homeserverUrl) return undefined;
const userId = mx.getUserId() ?? undefined;
let cancelled = false;
(async () => {
try {
await polling.saveSession({ accessToken: token, homeserverUrl, userId });
if (cancelled) return;
await polling.schedule(15);
if (cancelled) return;
await dumpRoomNamesToNative(mx);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[polling] lifecycle setup failed:', err);
}
})();
const onVisibility = () => {
if (cancelled) return;
if (typeof document === 'undefined') return;
if (document.visibilityState !== 'visible') return;
// Re-save credentials so a 401 clear inside the Worker (see
// VojoPollWorker.doWork) recovers as soon as the user comes back to
// the app — not only after a full remount. Then refresh the
// room-name snapshot. Both calls are idempotent overwrites.
(async () => {
try {
const currentToken = mx.getAccessToken();
if (cancelled) return;
if (currentToken) {
await polling.saveSession({
accessToken: currentToken,
homeserverUrl: mx.baseUrl,
userId: mx.getUserId() ?? undefined,
});
if (cancelled) return;
}
await dumpRoomNamesToNative(mx);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[polling] visibility re-bridge failed:', err);
}
})();
};
document.addEventListener('visibilitychange', onVisibility);
return () => {
cancelled = true;
document.removeEventListener('visibilitychange', onVisibility);
// Only stop the scheduled Worker here — DO NOT clearSession. The
// destructive wipe of access_token / watermark / NotificationDedup /
// room_names belongs only on real disable / logout (handled by
// useDisablePushNotifications, logoutClient, clearLocalSessionAndReload,
// and SessionLoggedOut). A bare effect cleanup runs on any mx-instance
// swap or unmount; clearing native state from there would silently
// erase the LRU and re-render events the next poll cycle.
polling.cancel().catch(noop);
};
}, [pushEnabled, mx]);
useEffect(() => {
if (!isNativePlatform()) return undefined;

View file

@ -16,6 +16,7 @@ import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
import { polling } from '../../plugins/polling';
function ClientRootLoading() {
return <AuthSplashScreen />;
@ -28,6 +29,11 @@ const useLogoutListener = (mx?: MatrixClient) => {
// access_token lingers in shared_prefs and CallDeclineReceiver spends
// the next login cycle posting 401s until writeSessionBridge overwrites.
await clearSessionBridge();
// Same applies to the WorkManager polling fallback: it has its own
// SharedPreferences-stored access_token that the React lifecycle
// cleanup wouldn't get a chance to clear before window.location.reload.
await polling.cancel();
await polling.clearSession();
mx?.stopClient();
await mx?.clearStores();
window.localStorage.clear();

View file

@ -0,0 +1,58 @@
// Bridge to the native PollingPlugin (see
// android/app/src/main/java/chat/vojo/app/PollingPlugin.java).
//
// Drives the WorkManager-based /notifications polling fallback used on
// networks where FCM (mtalk.google.com:5228) is blocked. JS owns the
// credential + room-name cache lifecycle; native owns the periodic fetch
// and notification rendering.
//
// Web has no analogue: the Service Worker already wakes for push without
// needing a periodic poll, and browsers don't expose a 15-minute periodic
// background API anyway. The web fallback is a no-op.
import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../utils/capacitor';
interface PollingPluginIface {
saveSession(opts: { accessToken: string; homeserverUrl: string; userId?: string }): Promise<void>;
clearSession(): Promise<void>;
saveRoomNames(opts: { names: Record<string, string> }): Promise<void>;
schedule(opts: { intervalMinutes: number }): Promise<void>;
cancel(): Promise<void>;
}
const noopPlugin: PollingPluginIface = {
saveSession: async () => undefined,
clearSession: async () => undefined,
saveRoomNames: async () => undefined,
schedule: async () => undefined,
cancel: async () => undefined,
};
const plugin = registerPlugin<PollingPluginIface>('Polling', {
web: noopPlugin,
});
const guard = async <T>(fn: () => Promise<T>, fallback: T): Promise<T> => {
if (!isAndroidPlatform()) return fallback;
try {
return await fn();
} catch (err) {
// Old APK installed before the plugin shipped, or transient bridge
// error. Swallow — polling is a best-effort backup channel, not a
// hard dependency on the foreground push lifecycle.
// eslint-disable-next-line no-console
console.warn('[polling] native call failed:', err);
return fallback;
}
};
export const polling = {
saveSession: (opts: { accessToken: string; homeserverUrl: string; userId?: string }) =>
guard(() => plugin.saveSession(opts), undefined),
clearSession: () => guard(() => plugin.clearSession(), undefined),
saveRoomNames: (names: Record<string, string>) =>
guard(() => plugin.saveRoomNames({ names }), undefined),
schedule: (intervalMinutes = 15) => guard(() => plugin.schedule({ intervalMinutes }), undefined),
cancel: () => guard(() => plugin.cancel(), undefined),
};

View file

@ -6,6 +6,7 @@ import { pushSessionToSW } from '../sw-session';
import { clearPusherIds, loadPusherIds, setPushEnabled, unregisterPusher } from '../app/utils/push';
import { isNativePlatform } from '../app/utils/capacitor';
import { clearSessionBridge } from '../app/utils/sessionBridge';
import { polling } from '../app/plugins/polling';
type Session = {
baseUrl: string;
@ -102,6 +103,15 @@ export const logoutClient = async (mx: MatrixClient) => {
clearPusherIds();
setPushEnabled(false);
// Tear down the WorkManager polling fallback synchronously here — the
// React lifecycle cleanup that does the same thing runs async and we
// don't get a chance to await it before window.location.replace below.
// Without this explicit await, the Worker can fire one more time with
// the old access_token and surface notifications belonging to the
// logged-out account.
await polling.cancel();
await polling.clearSession();
// Wipe the native session bridge so a re-login with a different user
// can't resurrect the old access_token via CallDeclineReceiver.
await clearSessionBridge();
@ -145,6 +155,12 @@ export const clearLoginData = async () => {
// will time out the session naturally.
export const clearLocalSessionAndReload = async () => {
await clearSessionBridge();
// Same reasoning as the normal logoutClient: kill the WorkManager
// polling fallback before the reload so it can't fire one more time
// with the old access_token and surface notifications from the
// logged-out account.
await polling.cancel();
await polling.clearSession();
clearPusherIds();
setPushEnabled(false);