From ebd2e95fe1ee5223d432324d086b1fc5c0da6efd Mon Sep 17 00:00:00 2001 From: MarekWo Date: Thu, 30 Jul 2026 18:19:52 +0200 Subject: [PATCH] fix: refill the message gap left by a backgrounded app The chat view only ever grew by socket push, so anything that arrived while the connection was down was never drawn. Android tears the connection down behind a locked screen, and the wrapper keeps the same page alive for days, so the list stopped at the last message that got through until the app was force-stopped. A browser tab hid the same bug by reloading the page on resume. Every way back from a gap now re-reads the list from the server: the socket reconnecting, the page becoming visible after more than a glance away, a heartbeat noticing its own tick arrived far too late (the page was frozen), a new Refresh item in the menu, and window.__mcAppResumed, which the wrapper calls from onResume since a WebView is not guaranteed to report the page as hidden at all. Direct messages get the same treatment. Verified in Chrome against the local container: all five triggers fire a resync, with no page errors and the list intact afterwards. Co-Authored-By: Claude Opus 5 --- android/src/app/build.gradle.kts | 4 +- .../it/wojtaszek/mc/wrapper/MainActivity.kt | 12 ++ app/static/js/app.js | 141 ++++++++++++++++-- app/static/js/dm.js | 60 ++++++-- app/templates/base.html | 10 ++ docs/whatsnew.md | 5 +- 6 files changed, 204 insertions(+), 28 deletions(-) diff --git a/android/src/app/build.gradle.kts b/android/src/app/build.gradle.kts index 96d95e2..97c673d 100644 --- a/android/src/app/build.gradle.kts +++ b/android/src/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "it.wojtaszek.mc.wrapper" minSdk = 21 targetSdk = 34 - versionCode = 2 - versionName = "1.1" + versionCode = 3 + versionName = "1.2" } buildTypes { diff --git a/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt index 879fd60..13a064e 100644 --- a/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt +++ b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt @@ -106,6 +106,18 @@ class MainActivity : AppCompatActivity() { if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(savedUrl) } + /** + * The page keeps running while the app sits in the background - that is how + * notifications keep arriving - but the connection behind it does not + * survive doze, so the message list can be minutes behind by the time the + * user looks at it again. A WebView is not guaranteed to tell the page it + * was ever hidden, so say it here instead and let mc-webui catch up. + */ + override fun onResume() { + super.onResume() + callJs("window.__mcAppResumed") + } + /** * A notification tap on a running app lands here rather than in [onCreate]. * When the app was not running, simply being launched is the whole point of diff --git a/app/static/js/app.js b/app/static/js/app.js index a3a9dbf..0b0ab36 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -391,6 +391,86 @@ function isContactProtectedByName(senderName) { return pubkey && protectedContactPubkeys.has(pubkey.toLowerCase()); } +// ============================================================================= +// Resync after a gap +// +// The chat view is push-driven: messages arrive over the socket and are +// appended one at a time. Whatever the socket misses - Android doze tearing the +// connection down behind a locked screen, a switch between Wi-Fi and mobile - +// is never drawn, and the list silently stops at the last message that got +// through. A browser tab hides this because it reloads the page on resume; the +// Android wrapper keeps the same page alive for days, so the gap stays until +// the app is force-stopped. +// +// So every way back from a gap ends up here: the socket reconnecting, the page +// becoming visible, the heartbeat noticing it was frozen, the Refresh menu +// item, and the wrapper's onResume hook. +// ============================================================================= + +let chatSocketEverConnected = false; +let resyncInFlight = false; + +/** Re-read the message list and badges from the server. */ +async function resyncFromServer(reason, { toast = false } = {}) { + if (resyncInFlight) return; + resyncInFlight = true; + console.log(`[resync] refreshing after ${reason}`); + try { + // The archive view is a frozen snapshot of one day - reloading it would + // fight the date the user picked. Its badges still need updating. + if (!currentArchiveDate) await loadMessages(); + await checkForUpdates(); + loadStatus(); + updatePendingContactsBadge(); + checkDmUpdates(); + if (toast) showNotification('Messages refreshed', 'success'); + } catch (error) { + console.error('[resync] failed:', error); + if (toast) showNotification('Refresh failed', 'danger'); + } finally { + resyncInFlight = false; + } +} + +/** + * Heartbeat that catches the cases no event announces. + * + * A tick arriving far later than scheduled means the page was frozen or + * throttled - precisely the window in which socket events go missing - so the + * gap is worth a resync even if the socket claims it never dropped. The same + * tick nudges a socket still stuck in its reconnect backoff. + */ +function startResyncHeartbeat() { + const TICK_MS = 20000; + let lastTickAt = Date.now(); + + setInterval(() => { + const now = Date.now(); + const drift = now - lastTickAt; + lastTickAt = now; + + if (document.hidden) return; // visibilitychange covers the way back + + if (drift > TICK_MS * 3) { + resyncFromServer('timer gap'); + return; + } + // connect() during backoff simply retries now instead of later; the + // 'connect' handler then does the resync + if (chatSocket && !chatSocket.connected) chatSocket.connect(); + }, TICK_MS); +} + +/** + * Called by the Android wrapper from onResume. The WebView is not guaranteed + * to fire visibilitychange for an Activity coming back to the foreground, so + * the wrapper says so itself. + */ +window.__mcAppResumed = function() { + if (chatSocket && !chatSocket.connected) chatSocket.connect(); + resyncFromServer('app resumed'); +}; + // Initialize on page load /** * Connect to SocketIO /chat namespace for real-time message updates @@ -412,6 +492,15 @@ function connectChatSocket() { chatSocket.on('connect', () => { console.log('SocketIO connected to /chat'); + // Everything pushed while the socket was down is gone for good - only a + // re-read of the list brings those messages back. Skipped on the very + // first connect, where DOMContentLoaded has just loaded them anyway. + if (chatSocketEverConnected) resyncFromServer('socket reconnect'); + chatSocketEverConnected = true; + }); + + chatSocket.on('disconnect', (reason) => { + console.warn('SocketIO /chat disconnected:', reason); }); chatSocket.on('connect_error', (err) => { @@ -582,6 +671,8 @@ document.addEventListener('DOMContentLoaded', async function() { // Connect SocketIO for real-time updates connectChatSocket(); + // Safety net for the updates the socket never delivered + startResyncHeartbeat(); console.log(`[init] UI ready in ${(performance.now() - initStart).toFixed(0)}ms`); @@ -609,23 +700,34 @@ window.addEventListener('pageshow', function(event) { }); // Handle app returning from background (PWA visibility change) +let hiddenSince = null; document.addEventListener('visibilitychange', function() { - if (!document.hidden) { - // App became visible again, force viewport recalculation - console.log('App became visible, recalculating viewport'); - setTimeout(() => { - window.scrollTo(0, 0); - window.dispatchEvent(new Event('resize')); - document.body.offsetHeight; - }, 100); - - // Clear app badge when user returns to app - if ('clearAppBadge' in navigator) { - navigator.clearAppBadge().catch((error) => { - console.error('Error clearing app badge on visibility:', error); - }); - } + if (document.hidden) { + hiddenSince = Date.now(); + return; } + + // App became visible again, force viewport recalculation + console.log('App became visible, recalculating viewport'); + setTimeout(() => { + window.scrollTo(0, 0); + window.dispatchEvent(new Event('resize')); + document.body.offsetHeight; + }, 100); + + // Clear app badge when user returns to app + if ('clearAppBadge' in navigator) { + navigator.clearAppBadge().catch((error) => { + console.error('Error clearing app badge on visibility:', error); + }); + } + + // Anything longer than a glance away is long enough for the socket to have + // dropped messages, so catch up before the user reads a stale list + const away = hiddenSince ? Date.now() - hiddenSince : 0; + hiddenSince = null; + if (chatSocket && !chatSocket.connected) chatSocket.connect(); + if (away > 10000) resyncFromServer('back from background'); }); /** @@ -943,6 +1045,15 @@ function setupEventListeners() { inst.hide(); }); + // Manual refresh from the menu + const refreshBtn = document.getElementById('refreshBtn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + if (chatSocket && !chatSocket.connected) chatSocket.connect(); + resyncFromServer('manual refresh', { toast: true }); + }); + } + // Notification toggle const notificationsToggle = document.getElementById('notificationsToggle'); if (notificationsToggle) { diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 93a9e7b..3432f1d 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -114,6 +114,31 @@ function resolveConversationName(conversationId) { return 'Unknown'; } +let chatSocketEverConnected = false; +let resyncInFlight = false; + +/** + * Re-read the DM lists from the server. + * + * This view is push-driven, so a socket that drops while the phone sleeps + * leaves it showing whatever arrived last - the 60s poll eventually catches up, + * but only once its timer un-throttles. Every path back from a gap comes here. + */ +async function resyncFromServer(reason) { + if (resyncInFlight) return; + resyncInFlight = true; + console.log(`DM: [resync] refreshing after ${reason}`); + try { + await loadConversations(); + if (currentConversationId) await loadMessages(); + await loadStatus(); + } catch (error) { + console.error('DM: [resync] failed:', error); + } finally { + resyncInFlight = false; + } +} + /** * Connect to SocketIO /chat namespace for real-time DM and ACK updates */ @@ -134,10 +159,14 @@ function connectChatSocket() { chatSocket.on('connect', () => { console.log('DM: SocketIO connected to /chat'); + // Whatever arrived while the socket was down was never pushed to us; + // only re-reading the lists brings it back (see resyncFromServer) + if (chatSocketEverConnected) resyncFromServer('socket reconnect'); + chatSocketEverConnected = true; }); - chatSocket.on('disconnect', () => { - console.log('DM: SocketIO disconnected'); + chatSocket.on('disconnect', (reason) => { + console.log('DM: SocketIO disconnected:', reason); }); // Real-time new DM message @@ -330,16 +359,27 @@ window.addEventListener('pageshow', function(event) { }); // Handle app returning from background (PWA visibility change) +let hiddenSince = null; document.addEventListener('visibilitychange', function() { - if (!document.hidden) { - // App became visible again, force viewport recalculation - console.log('App became visible, recalculating viewport'); - setTimeout(() => { - window.scrollTo(0, 0); - window.dispatchEvent(new Event('resize')); - document.body.offsetHeight; - }, 100); + if (document.hidden) { + hiddenSince = Date.now(); + return; } + + // App became visible again, force viewport recalculation + console.log('App became visible, recalculating viewport'); + setTimeout(() => { + window.scrollTo(0, 0); + window.dispatchEvent(new Event('resize')); + document.body.offsetHeight; + }, 100); + + // Long enough away for the socket to have dropped updates - catch up + // rather than wait for the next 60s poll + const away = hiddenSince ? Date.now() - hiddenSince : 0; + hiddenSince = null; + if (chatSocket && !chatSocket.connected) chatSocket.connect(); + if (away > 10000) resyncFromServer('back from background'); }); /** diff --git a/app/templates/base.html b/app/templates/base.html index 742d3a2..224151b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -111,6 +111,16 @@
+ +