diff --git a/android/app/build.gradle b/android/app/build.gradle index a4f6f490..989cbaa0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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" diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java index c2d87264..7339e82d 100644 --- a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java @@ -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(); } diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index d7e70e97..7a9c7292 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -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 diff --git a/android/app/src/main/java/chat/vojo/app/NotificationDedup.java b/android/app/src/main/java/chat/vojo/app/NotificationDedup.java new file mode 100644 index 00000000..4a3d422a --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/NotificationDedup.java @@ -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 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 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 readSet(Context ctx) { + SharedPreferences prefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + String raw = prefs.getString(VojoPollWorker.KEY_NOTIFIED_IDS, ""); + Set 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 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(); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/PollingPlugin.java b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java new file mode 100644 index 00000000..9c3cfb32 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java @@ -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()); + } + } +} diff --git a/android/app/src/main/java/chat/vojo/app/PushStrings.java b/android/app/src/main/java/chat/vojo/app/PushStrings.java index ff0437f0..267dbfb1 100644 --- a/android/app/src/main/java/chat/vojo/app/PushStrings.java +++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java @@ -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 diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index e41d10c7..0f505c86 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -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 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 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 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 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 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; } } diff --git a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java new file mode 100644 index 00000000..8de9e4e8 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java @@ -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 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 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/" 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 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 flattenNotification( + JSONObject entry, Map roomNames + ) { + Map 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 loadRoomNamesMap(SharedPreferences prefs) { + Map 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 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 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; + } + + +} diff --git a/docs/ai/android.md b/docs/ai/android.md index 072c9b88..1f214f4d 100644 --- a/docs/ai/android.md +++ b/docs/ai/android.md @@ -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`, 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('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. diff --git a/public/locales/en.json b/public/locales/en.json index 28ffc6e3..3743d690 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index f2a91e1f..04bb80dc 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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}} не подключён", diff --git a/scripts/gen-push-strings.mjs b/scripts/gen-push-strings.mjs index 2a1780dc..374e5d4a 100644 --- a/scripts/gen-push-strings.mjs +++ b/scripts/gen-push-strings.mjs @@ -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) { '-->', '', ]; - 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(` ${xmlEscape(text)}`); - } + }); lines.push(''); 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 { diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index 97fc0193..931af8f6 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -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 => { + const names: Record = mx + .getRooms() + .reduce>((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 { // 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; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 9592ea2c..af9bce10 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -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 ; @@ -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(); diff --git a/src/app/plugins/polling.ts b/src/app/plugins/polling.ts new file mode 100644 index 00000000..3fc8c4a7 --- /dev/null +++ b/src/app/plugins/polling.ts @@ -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; + clearSession(): Promise; + saveRoomNames(opts: { names: Record }): Promise; + schedule(opts: { intervalMinutes: number }): Promise; + cancel(): Promise; +} + +const noopPlugin: PollingPluginIface = { + saveSession: async () => undefined, + clearSession: async () => undefined, + saveRoomNames: async () => undefined, + schedule: async () => undefined, + cancel: async () => undefined, +}; + +const plugin = registerPlugin('Polling', { + web: noopPlugin, +}); + +const guard = async (fn: () => Promise, fallback: T): Promise => { + 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) => + guard(() => plugin.saveRoomNames({ names }), undefined), + schedule: (intervalMinutes = 15) => guard(() => plugin.schedule({ intervalMinutes }), undefined), + cancel: () => guard(() => plugin.cancel(), undefined), +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 0d3f67ea..54504290 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -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);