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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-30 18:19:52 +02:00
parent dc685348f9
commit ebd2e95fe1
6 changed files with 204 additions and 28 deletions
+2 -2
View File
@@ -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 {
@@ -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
+126 -15
View File
@@ -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) {
+50 -10
View File
@@ -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');
});
/**
+10
View File
@@ -111,6 +111,16 @@
<div class="offcanvas-body">
<div class="list-group list-group-flush">
<!-- Messages -->
<!-- The Android wrapper has no pull-to-refresh of its own, so
this is the manual way out of a list that fell behind -->
<button id="refreshBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" type="button"
data-bs-dismiss="offcanvas" title="Reload messages from the server">
<i class="bi bi-arrow-clockwise" style="font-size: 1.5rem;"></i>
<div class="flex-grow-1">
<div>Refresh</div>
<small class="d-block text-muted">Reload messages from server</small>
</div>
</button>
<button id="menu-filter" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" type="button">
<i class="bi bi-funnel" style="font-size: 1.5rem;"></i>
<div class="flex-grow-1">
+4 -1
View File
@@ -10,7 +10,10 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
## Unreleased
_Nothing yet since 2.4.0._
### Fixes
- **Messages no longer go missing after the app has been in the background.** Coming back to a minimised app — or to a phone that had been asleep — could show a chat that quietly stopped at whatever message arrived last before the screen went off, with everything since then missing until the app was force-stopped and reopened. New messages reach an open page over a live connection, and Android tears that connection down while the app sits in the background; nothing then went back to ask the server what had been missed. Now every way back from a gap re-reads the list: the connection coming back, the app returning to the foreground, and a heartbeat that notices when the page has been frozen. The same applies to direct messages, and to a browser tab that lost its network for a while.
- **A Refresh item in the menu.** The browser's pull-to-refresh has no equivalent in the Android app, so there is now a **Refresh** entry at the top of the menu that reloads the messages from the server on demand — in the app, and everywhere else too.
---