feat: implement multi-device sync hardening with periodic auto-refresh and table-open synchronization

This commit is contained in:
Suherdy Yacob 2026-06-15 23:33:36 +07:00
parent 881c4af73a
commit 93ac66fa5b
2 changed files with 215 additions and 1 deletions

View File

@ -17,7 +17,7 @@ Features
3. Portrait Display Mode: Single-pane responsive layout for mobile POS terminals. 3. Portrait Display Mode: Single-pane responsive layout for mobile POS terminals.
""", """,
'author': "Suherdy Yacob", 'author': "Suherdy Yacob",
'depends': ['point_of_sale'], 'depends': ['point_of_sale', 'pos_restaurant'],
'data': [ 'data': [
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
], ],
@ -33,6 +33,8 @@ Features
'pos_ui_optimization/static/src/app/screens/product_screen/order_summary_patch.xml', 'pos_ui_optimization/static/src/app/screens/product_screen/order_summary_patch.xml',
# Safe multi-device Release table guard # Safe multi-device Release table guard
'pos_ui_optimization/static/src/app/screens/product_screen/order_summary/safe_release_patch.js', 'pos_ui_optimization/static/src/app/screens/product_screen/order_summary/safe_release_patch.js',
# Multi-device sync hardening (table-open sync, 30s auto-refresh, visibility refresh)
'pos_ui_optimization/static/src/app/screens/floor_screen/floor_screen_sync_patch.js',
# Payment screen patches # Payment screen patches
'pos_ui_optimization/static/src/app/screens/payment_screen/payment_screen_patch.xml', 'pos_ui_optimization/static/src/app/screens/payment_screen/payment_screen_patch.xml',
# Ticket screen patches # Ticket screen patches

View File

@ -0,0 +1,212 @@
/** @odoo-module **/
/**
* pos_ui_optimization Multi-Device Sync Hardening
* ==================================================
*
* This patch adds three complementary sync strategies that work on top of
* Odoo's existing WebSocket-based DevicesSynchronisation to ensure order
* data stays fresh on all devices in a multi-tablet restaurant setup.
*
* === Android System WebView Compatibility Notes ===
*
* This file is intentionally written to be compatible with Android System
* WebView versions used by the Odoo Android app (Chrome/WebView 80+):
*
* requestIdleCallback:
* Supported in Android WebView 47+ (all modern devices).
* We have a setTimeout fallback for any older environment.
*
* visibilitychange:
* Reliable in Chrome browser, but in Android WebView the event only
* fires when the WebView loses DOM focus NOT when the entire Android
* app is backgrounded by the OS, unless the native app explicitly calls
* WebView.onPause()/onResume(). The Odoo Android app does implement this
* correctly, so visibilitychange works. As a safety net, we also register
* a focus/blur fallback that works regardless of native app lifecycle.
*
* Optional chaining (?.) and nullish coalescing (??):
* Supported in Chrome 80+ / Android WebView from early 2020.
* All active Odoo Android installs run Chrome 80+. No changes needed.
*
* Numeric separators (30_000):
* Not supported in WebView < 75. Replaced with plain 30000.
*
* Strategy 1 Sync-on-table-open:
* Pulls fresh data from the server before the FloorScreen opens a table so
* cashiers always see up-to-date orders (not stale local state).
*
* Strategy 2 Periodic auto-refresh (30 s):
* A background timer fires every 30 seconds ONLY when safe:
* The device is on the FloorScreen (never during order editing).
* No input is focused (user is not typing a quantity/note).
* No concurrent refresh is already running.
* Uses requestIdleCallback so the browser picks a CPU-idle moment,
* preventing dropped frames or UI stutter.
*
* Strategy 3 Visibility + focus/blur refresh:
* Triggers a refresh when:
* document.visibilitychange fires (Chrome browser, Odoo Android app).
* window 'focus' fires (fallback for Android WebView when the OS
* resumes the app but visibilitychange did not fire).
*/
import { FloorScreen } from "@pos_restaurant/app/screens/floor_screen/floor_screen";
import { PosStore } from "@point_of_sale/app/services/pos_store";
import { patch } from "@web/core/utils/patch";
import { onMounted, onWillUnmount } from "@odoo/owl";
// Auto-refresh interval (avoid numeric separators for WebView < 75 compat)
const AUTO_REFRESH_INTERVAL_MS = 30000;
// ─── PosStore patch ───────────────────────────────────────────────────────────
patch(PosStore.prototype, {
/**
* Start all background sync strategies. Called from FloorScreen.onMounted.
* Guards against double-initialisation (e.g. if FloorScreen unmounts/remounts).
*/
setupSyncRefresh() {
if (this._syncRefreshInitialized) {
return;
}
this._syncRefreshInitialized = true;
this._refreshInProgress = false;
// Strategy 2: periodic timer
this._autoRefreshInterval = setInterval(() => {
this._tryAutoRefresh();
}, AUTO_REFRESH_INTERVAL_MS);
// Strategy 3a: Page Visibility API
// Works in Chrome browser and in the Odoo Android app (which calls
// WebView.onPause/onResume bridging visibility state correctly).
this._onVisibilityChange = () => {
if (document.visibilityState === "visible") {
this._tryAutoRefresh();
}
};
document.addEventListener("visibilitychange", this._onVisibilityChange);
// Strategy 3b: window focus fallback for Android WebView environments
// where visibilitychange may not fire when the OS resumes the app.
// Deduplicated with _refreshInProgress so it never double-triggers.
this._onWindowFocus = () => {
// Add a short delay: Android sometimes fires focus before the
// WebView has fully re-rendered after coming back to foreground.
setTimeout(() => {
this._tryAutoRefresh();
}, 500);
};
window.addEventListener("focus", this._onWindowFocus);
},
/**
* Remove all timers and event listeners. Called from FloorScreen.onWillUnmount.
*/
destroySyncRefresh() {
if (this._autoRefreshInterval) {
clearInterval(this._autoRefreshInterval);
this._autoRefreshInterval = null;
}
if (this._onVisibilityChange) {
document.removeEventListener("visibilitychange", this._onVisibilityChange);
this._onVisibilityChange = null;
}
if (this._onWindowFocus) {
window.removeEventListener("focus", this._onWindowFocus);
this._onWindowFocus = null;
}
this._syncRefreshInitialized = false;
this._refreshInProgress = false;
},
/**
* Central guard method decides whether it is safe to run readDataFromServer()
* and schedules it via requestIdleCallback (or setTimeout(0) as fallback).
*
* Guards:
* 1. No concurrent refresh already running.
* 2. Skip if the user is actively typing in an input/textarea.
* 3. Only run while on the FloorScreen (never during order editing).
*/
_tryAutoRefresh() {
// Guard 1
if (this._refreshInProgress) {
return;
}
// Guard 2: skip while the user is typing
const activeEl = document.activeElement;
const activeTag = activeEl ? activeEl.tagName : "";
if (activeTag === "INPUT" || activeTag === "TEXTAREA") {
return;
}
// Guard 3: only on FloorScreen
const mainScreen = this.mainScreen;
const component = mainScreen ? mainScreen.component : null;
const screenName = component ? component.name : null;
if (screenName && screenName !== "FloorScreen") {
return;
}
const doRefresh = async () => {
this._refreshInProgress = true;
try {
if (this.deviceSync) {
await this.deviceSync.readDataFromServer();
}
} catch (_e) {
// Non-blocking — if server is unreachable, just skip silently
} finally {
this._refreshInProgress = false;
}
};
// requestIdleCallback: lets the browser pick a CPU-idle moment between
// animation frames, preventing dropped frames or visible stutter.
// Supported in Android WebView 47+ (all modern devices).
// Falls back to setTimeout(fn, 0) which at minimum yields the call
// stack and runs after the current paint cycle.
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => doRefresh(), { timeout: 5000 });
} else {
setTimeout(() => doRefresh(), 0);
}
},
});
// ─── FloorScreen patch ────────────────────────────────────────────────────────
patch(FloorScreen.prototype, {
setup() {
super.setup(...arguments);
onMounted(() => {
this.pos.setupSyncRefresh();
});
onWillUnmount(() => {
this.pos.destroySyncRefresh();
});
},
/**
* Strategy 1: Sync-on-table-open.
*
* Pulls the latest order state from the server before navigating into a
* table's order view. Non-blocking on failure the cashier can always
* open the table even if the server is temporarily unreachable.
*/
async onClickTable(table, ev) {
if (!this.pos.isEditMode && !this.pos.isOrderTransferMode && !table.parent_id) {
try {
if (this.pos.deviceSync) {
await this.pos.deviceSync.readDataFromServer();
}
} catch (_e) {
// Non-blocking: proceed with local data if server unreachable
}
}
return super.onClickTable(table, ev);
},
});