mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
fix(native): a tick on the watch, the phone or another member's device reaches every watch at once
The watch's snapshot was rebuilt every fifteen minutes, so a tick in the app or one arriving over the WebSocket did not reach the watch until then. It is now rebuilt as soon as the packing lists or the to-dos change in the offline tables. A tick on the watch goes as a message while the phone is in reach, which wakes the phone app, and a phone app in the background writes it to the server itself, so the trip's other devices hear of it at once instead of when the phone app is next opened.
This commit is contained in:
@@ -2,6 +2,7 @@ import CryptoKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WatchConnectivity
|
||||
import WebKit
|
||||
|
||||
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
|
||||
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
|
||||
@@ -110,6 +111,25 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
return list
|
||||
}
|
||||
|
||||
/// While the app is in front, its UI writes the tick, through its offline queue
|
||||
/// as a tap would. In the background the WebView is asleep, so the tick is
|
||||
/// written to the server from here at once (TrekWatchWriter), and the trip's
|
||||
/// other devices hear of it over their WebSocket; only a write that fails is
|
||||
/// queued for the UI, which then tries again once it runs.
|
||||
private func receive(_ action: [String: Any], then done: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
guard UIApplication.shared.applicationState != .active else {
|
||||
self.enqueue(action)
|
||||
done()
|
||||
return
|
||||
}
|
||||
TrekWatchWriter.write(action) { written in
|
||||
if !written { self.enqueue(action) }
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ action: [String: Any]) {
|
||||
queue.async {
|
||||
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
|
||||
@@ -145,11 +165,16 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
if let action = userInfo["action"] as? [String: Any] { enqueue(action) }
|
||||
if let action = userInfo["action"] as? [String: Any] { receive(action) {} }
|
||||
}
|
||||
|
||||
/// The watch asks for the latest snapshot when it opens while the phone is in reach.
|
||||
/// The watch asks for the latest snapshot when it opens while the phone is in
|
||||
/// reach, and sends its ticks this way while it is (WatchLink.send).
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
if let action = message["action"] as? [String: Any] {
|
||||
receive(action) { replyHandler(["queued": true]) }
|
||||
return
|
||||
}
|
||||
queue.async {
|
||||
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
|
||||
replyHandler(["snapshot": data])
|
||||
@@ -159,3 +184,72 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a tick from the watch straight to the TREK server, for when the app is
|
||||
/// in the background and its UI cannot. The same request the UI makes for a tap
|
||||
/// (PUT /api/trips/:trip/packing|todo/:item with the box's new state), with the
|
||||
/// session the WebView holds (its cookies) and the tick's id as the idempotency
|
||||
/// key, so a tick that also reaches the server another way is taken once.
|
||||
enum TrekWatchWriter {
|
||||
private final class BackgroundTask {
|
||||
var id = UIBackgroundTaskIdentifier.invalid
|
||||
|
||||
func end() {
|
||||
guard id != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(id)
|
||||
id = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on the main queue, as is `completion`.
|
||||
static func write(_ action: [String: Any], completion: @escaping (Bool) -> Void) {
|
||||
guard let request = request(for: action) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
let task = BackgroundTask()
|
||||
task.id = UIApplication.shared.beginBackgroundTask(withName: "trek.watch.tick") { task.end() }
|
||||
let finish: (Bool) -> Void = { written in
|
||||
DispatchQueue.main.async {
|
||||
completion(written)
|
||||
task.end()
|
||||
}
|
||||
}
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
guard let host = request.url?.host else { return finish(false) }
|
||||
let session = cookies.filter { cookie in
|
||||
let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
|
||||
return host == domain || host.hasSuffix("." + domain)
|
||||
}
|
||||
guard !session.isEmpty else { return finish(false) }
|
||||
var signed = request
|
||||
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
|
||||
signed.setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
URLSession.shared.dataTask(with: signed) { _, response, error in
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
finish(error == nil && (200..<300).contains(status))
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// The write for a tick as the watch sends it, or nil for anything else.
|
||||
static func request(for action: [String: Any]) -> URLRequest? {
|
||||
guard let server = TrekServerStore.url, let base = URL(string: server),
|
||||
base.scheme == "https" || base.scheme == "http",
|
||||
let id = action["id"] as? String, !id.isEmpty, id.count <= 64,
|
||||
let tripId = action["tripId"] as? Int, let itemId = action["itemId"] as? Int,
|
||||
let list = action["list"] as? String, list == "packing" || list == "todo",
|
||||
let done = action["done"] as? Bool,
|
||||
let body = try? JSONSerialization.data(withJSONObject: ["checked": done]) else { return nil }
|
||||
let url = base.appendingPathComponent("api").appendingPathComponent("trips")
|
||||
.appendingPathComponent(String(tripId)).appendingPathComponent(list).appendingPathComponent(String(itemId))
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.httpMethod = "PUT"
|
||||
request.httpShouldHandleCookies = false
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("watch-\(id)", forHTTPHeaderField: "X-Idempotency-Key")
|
||||
request.httpBody = body
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,27 @@ final class WatchLink: NSObject, WCSessionDelegate {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
|
||||
/// A tick to the phone. In reach it goes as a message, which arrives at once and
|
||||
/// wakes the phone app for it, so the trip's other devices see it within seconds.
|
||||
/// Out of reach, or when the message fails or a phone app too old to take it
|
||||
/// answers without "queued", it goes as user info, which the system delivers later.
|
||||
func send(_ action: PendingAction) {
|
||||
guard WCSession.default.activationState == .activated else { return }
|
||||
WCSession.default.transferUserInfo(["action": action.message])
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else { return }
|
||||
let message: [String: Any] = ["action": action.message]
|
||||
guard session.isReachable else {
|
||||
session.transferUserInfo(message)
|
||||
return
|
||||
}
|
||||
session.sendMessage(message, replyHandler: { reply in
|
||||
if reply["queued"] as? Bool == true {
|
||||
Task { @MainActor in WatchModel.shared.markSent(action.id) }
|
||||
} else {
|
||||
session.transferUserInfo(message)
|
||||
}
|
||||
}, errorHandler: { _ in
|
||||
session.transferUserInfo(message)
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
|
||||
import { onWatchListsChange } from '../../native/watchListChanges';
|
||||
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
||||
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||
@@ -229,8 +230,11 @@ export default function NativeWidgetsSync(): null {
|
||||
if (isActive) void sync().then(drainWatch);
|
||||
});
|
||||
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
|
||||
// A tick here, or one another member made, reaches the watch at once rather than on the next refresh.
|
||||
const stopListWatch = watchActive() ? onWatchListsChange(() => void sync()) : null;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopListWatch?.();
|
||||
window.clearInterval(timer);
|
||||
void listener.then((handle) => handle.remove());
|
||||
void watchListener?.then((handle) => handle.remove());
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { offlineDb } from '../db/offlineDb';
|
||||
import type { PackingItem, TodoItem } from '../types';
|
||||
import { onWatchListsChange } from './watchListChanges';
|
||||
|
||||
// FE-NATIVE-WATCHSYNC-001 onwards: the watch hears of a list change at once, not on the next refresh.
|
||||
|
||||
const packing = (id: number, checked: number) =>
|
||||
({ id, trip_id: 1, name: `Item ${id}`, checked }) as unknown as PackingItem;
|
||||
const todo = (id: number, checked: number) => ({ id, trip_id: 1, name: `Task ${id}`, checked }) as unknown as TodoItem;
|
||||
|
||||
/** Long enough for the live query to answer and the settle window to pass. */
|
||||
const settled = () => new Promise((resolve) => setTimeout(resolve, 120));
|
||||
|
||||
describe('onWatchListsChange', () => {
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await offlineDb.packingItems.clear();
|
||||
await offlineDb.todoItems.clear();
|
||||
await offlineDb.packingItems.put(packing(1, 0));
|
||||
await offlineDb.todoItems.put(todo(2, 0));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
});
|
||||
|
||||
it('FE-NATIVE-WATCHSYNC-001: subscribing is no change, a tick on the packing list is', async () => {
|
||||
const run = vi.fn();
|
||||
stop = onWatchListsChange(run, 20);
|
||||
await settled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
|
||||
await offlineDb.packingItems.put(packing(1, 1));
|
||||
await settled();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-WATCHSYNC-002: a to-do ticked, here or by another member, counts as well', async () => {
|
||||
const run = vi.fn();
|
||||
stop = onWatchListsChange(run, 20);
|
||||
await settled();
|
||||
await offlineDb.todoItems.put(todo(2, 1));
|
||||
await settled();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-WATCHSYNC-003: a run of ticks settles into one refresh', async () => {
|
||||
const run = vi.fn();
|
||||
stop = onWatchListsChange(run, 60);
|
||||
await settled();
|
||||
await offlineDb.packingItems.put(packing(1, 1));
|
||||
await offlineDb.packingItems.put(packing(3, 0));
|
||||
await offlineDb.todoItems.put(todo(2, 1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-WATCHSYNC-004: after the unsubscribe nothing is called any more', async () => {
|
||||
const run = vi.fn();
|
||||
stop = onWatchListsChange(run, 20);
|
||||
await settled();
|
||||
stop();
|
||||
stop = null;
|
||||
await offlineDb.packingItems.put(packing(1, 1));
|
||||
await settled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { liveQuery } from 'dexie';
|
||||
import { offlineDb } from '../db/offlineDb';
|
||||
|
||||
/** Long enough to take a run of ticks (or a synced batch) as one change, short enough to read as instant. */
|
||||
const SETTLE_MS = 300;
|
||||
|
||||
/**
|
||||
* Calls `run` once the packing lists or the to-dos this device keeps offline
|
||||
* have changed, after they settled: a tick in the app, a tick from the watch
|
||||
* written through, or another member's tick arriving over the WebSocket (the
|
||||
* remote event handler writes it to the same tables). The watch's snapshot is
|
||||
* built from those tables, so this is what keeps the watch as current as the
|
||||
* phone instead of waiting for the next quarter-hour refresh.
|
||||
*
|
||||
* The first answer of the query is the state at subscription, not a change, and
|
||||
* is skipped. Returns the unsubscribe.
|
||||
*/
|
||||
export function onWatchListsChange(run: () => void, settleMs = SETTLE_MS): () => void {
|
||||
let first = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const subscription = liveQuery(() =>
|
||||
Promise.all([offlineDb.packingItems.toArray(), offlineDb.todoItems.toArray()])
|
||||
).subscribe({
|
||||
next: () => {
|
||||
if (first) {
|
||||
first = false;
|
||||
return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
run();
|
||||
}, settleMs);
|
||||
},
|
||||
// A failed read changes nothing on the watch; the next write tries again.
|
||||
error: () => undefined,
|
||||
});
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user