feat(push): add WorkManager polling fallback that delivers notifications via /_matrix/client/v3/notifications when FCM is blocked
This commit is contained in:
parent
b9aad691b5
commit
408f165f60
16 changed files with 1454 additions and 54 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
104
android/app/src/main/java/chat/vojo/app/NotificationDedup.java
Normal file
104
android/app/src/main/java/chat/vojo/app/NotificationDedup.java
Normal 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();
|
||||
}
|
||||
}
|
||||
188
android/app/src/main/java/chat/vojo/app/PollingPlugin.java
Normal file
188
android/app/src/main/java/chat/vojo/app/PollingPlugin.java
Normal 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
|
||||
// disable→reenable 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
607
android/app/src/main/java/chat/vojo/app/VojoPollWorker.java
Normal file
607
android/app/src/main/java/chat/vojo/app/VojoPollWorker.java
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}} не подключён",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
58
src/app/plugins/polling.ts
Normal file
58
src/app/plugins/polling.ts
Normal 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),
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue