diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 6eb97db..85fb1c6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -46,6 +46,9 @@ public final class CloudCarrier { private let scheduler: Scheduler private let makeSocket: WebSocketFactory private var looking: Bool + /// What the service last heard: message 1's `hz`, then each `watch`. + /// Nil until message 1 of this dial goes. + private var told: Bool? private var socket: WebSocketConnection? private var generation = 0 @@ -91,11 +94,22 @@ public final class CloudCarrier { /// Whether anyone can see the home. While someone can, the service keeps /// the box at one update a second. A closed session takes the change - /// with it into message 1 of the next. + /// with it into message 1 of the next. A session still opening says it + /// once message 2 is in: a launch passes through inactive, so message 1 + /// often says the phone looks away. public func setLooking(_ on: Bool) { guard on != looking else { return } looking = on - send(CloudWire.msgWatch, .map([("hz", .int(on ? CloudWire.hzLooking : CloudWire.hzAway))])) + tellLooking() + } + + /// Send `watch` when the service last heard otherwise and the session + /// is open. + private func tellLooking() { + guard let told, told != looking else { return } + if send(CloudWire.msgWatch, .map([("hz", .int(looking ? CloudWire.hzLooking : CloudWire.hzAway))])) { + self.told = looking + } } /// An owner suspends a device at FTW Cloud, or lifts it. It only narrows @@ -151,6 +165,7 @@ public final class CloudCarrier { let mine = generation handshake = nil transport = nil + told = nil socket = makeSocket(endpoint.url(home: home), WebSocketEvents( onOpen: { [weak self] in guard let self, mine == self.generation else { return } @@ -182,6 +197,7 @@ public final class CloudCarrier { guard let msg1 = try? hs.writeMessage(now) else { return } handshake = hs socket.send(msg1) + told = looking deadline?.cancel() deadline = scheduler.after(Self.handshakeTimeoutMs) { [weak self] in guard let self, self.transport == nil else { return } @@ -216,6 +232,7 @@ public final class CloudCarrier { attempt = 0 refusals = 0 setStatus(.open(sinceMs: arrival)) + tellLooking() onSnapshot(snapshot, arrival) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index b819b8d..5efabea 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -217,6 +217,46 @@ final class CloudRig { #expect(rig.service.watches == [1, 0]) } + /// A launch passes through inactive, so message 1 may say the phone + /// looks away. The app comes to the front before message 2: the service + /// hears it once the session opens, or the box stays at 5 s. + @Test func lookingChangedDuringTheHandshakeIsSaidOnceOpen() async throws { + let rig = try CloudRig(looking: false) + rig.carrier.start() + await rig.run(15) + #expect(rig.service.nows == []) + #expect(!rig.carrier.status.isOpen) + rig.carrier.setLooking(true) + await rig.run(50) + #expect(rig.service.nows == [CloudWire.hzAway]) + #expect(rig.carrier.status.isOpen) + #expect(rig.service.watches == [1]) + + // And the other way: the phone looked when it dialled and looked + // away before the session opened. + let away = try CloudRig(looking: true) + away.carrier.start() + await away.run(15) + away.carrier.setLooking(false) + await away.run(50) + #expect(away.service.nows == [CloudWire.hzLooking]) + #expect(away.service.watches == [0]) + } + + /// A change while the session is down rides in the next message 1, and + /// nothing more is said. + @Test func lookingChangedWhileClosedGoesInMessage1() async throws { + let rig = try CloudRig(looking: true) + rig.carrier.start() + await rig.run(50) + rig.service.close(code: 1006) + rig.carrier.setLooking(false) + await rig.run(600) + #expect(rig.carrier.status.isOpen) + #expect(rig.service.nows == [CloudWire.hzLooking, CloudWire.hzAway]) + #expect(rig.service.watches == []) + } + /// A refusal is asked again slowly, as after a service restart that has /// not heard from the box yet, and at once on the next foreground. @Test func aRefusalIsAskedAgainSlowly() async throws {