diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c611a5fdc83..72ab7e5c324b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3295,8 +3295,10 @@ jobs: -destination "platform=iOS Simulator,id=${simulator_id}" \ -resultBundlePath "$result_bundle" \ -parallel-testing-enabled NO \ + -only-testing:OpenClawTests/DelayedActionGateTests \ -only-testing:OpenClawTests/NodeAppModelInvokeTests \ -only-testing:OpenClawTests/NotificationServingPreferenceTests \ + -only-testing:OpenClawTests/RootTabsSourceGuardTests \ test - name: Upload iOS lifecycle simulator evidence diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index b37b0749318d..42894323c43a 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -27163,7 +27163,7 @@ }, { "kind": "conditional-branch", - "line": 870, + "line": 873, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway needs attention", "surface": "apple", @@ -27171,7 +27171,7 @@ }, { "kind": "conditional-branch", - "line": 870, + "line": 873, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw iOS", "surface": "apple", @@ -27179,7 +27179,7 @@ }, { "kind": "conditional-branch", - "line": 906, + "line": 909, "path": "apps/ios/Sources/RootTabs.swift", "source": "Available", "surface": "apple", @@ -27187,7 +27187,7 @@ }, { "kind": "conditional-branch", - "line": 906, + "line": 909, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway default", "surface": "apple", @@ -27195,7 +27195,7 @@ }, { "kind": "conditional-branch", - "line": 906, + "line": 909, "path": "apps/ios/Sources/RootTabs.swift", "source": "Routed on this phone", "surface": "apple", diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 57b1686fd7d5..054673b80f26 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -45,7 +45,7 @@ struct RootTabs: View { @State private var isSidebarDrawerLayout: Bool = false @State private var didResolveSidebarLayout: Bool = false @State private var voiceWakeToastText: String? - @State private var toastDismissTask: Task? + @State private var toastDismissGate = DelayedActionGate() @State private var presentedSheet: PresentedSheet? @State private var showGatewayProblemDetails: Bool = false @State private var gatewayToastDragOffset: CGFloat = 0 @@ -520,9 +520,9 @@ struct RootTabs: View { } .overlay { - if self.appModel.cameraFlashNonce != 0 { - RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) - } + // Keep the observer mounted so the first 0 -> 1 capture transition + // flashes without treating a later remount as a new capture. + RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) } .overlay { if self.appModel.screen.isCanvasPresented { @@ -624,17 +624,13 @@ struct RootTabs: View { let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - self.toastDismissTask?.cancel() withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) { self.voiceWakeToastText = trimmed } - self.toastDismissTask = Task { - try? await Task.sleep(nanoseconds: 2_300_000_000) - await MainActor.run { - withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) { - self.voiceWakeToastText = nil - } + self.toastDismissGate.schedule(after: .milliseconds(2300)) { + withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) { + self.voiceWakeToastText = nil } } } @@ -654,7 +650,10 @@ struct RootTabs: View { .onChange(of: self.scenePhase) { _, newValue in self.updateIdleTimer() self.updateHomeCanvasState() - guard newValue == .active else { return } + guard newValue == .active else { + self.clearVoiceWakeToast() + return + } self.maybeRequestLocalNetworkAccess(reason: "scene_active") Task { await self.appModel.refreshGatewayOverviewIfConnected() @@ -665,11 +664,15 @@ struct RootTabs: View { } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false - self.toastDismissTask?.cancel() - self.toastDismissTask = nil + self.clearVoiceWakeToast() } } + private func clearVoiceWakeToast() { + self.voiceWakeToastText = nil + self.toastDismissGate.cancel() + } + private func rootGatewayProblemLifecycle(_ content: some View) -> some View { content .onChange(of: self.appModel.lastGatewayProblem) { _, newValue in @@ -1182,10 +1185,12 @@ extension RootTabs { } private struct RootCameraFlashOverlay: View { + @Environment(\.scenePhase) private var scenePhase + var nonce: Int @State private var opacity: CGFloat = 0 - @State private var task: Task? + @State private var dismissGate = DelayedActionGate() var body: some View { Color.white @@ -1193,21 +1198,33 @@ private struct RootCameraFlashOverlay: View { .ignoresSafeArea() .allowsHitTesting(false) .onChange(of: self.nonce) { _, _ in - self.task?.cancel() - self.task = Task { @MainActor in - withAnimation(.easeOut(duration: 0.08)) { - self.opacity = 0.85 - } - try? await Task.sleep(nanoseconds: 110_000_000) - withAnimation(.easeOut(duration: 0.32)) { - self.opacity = 0 - } + guard self.scenePhase == .active else { + self.clearFlash() + return } + self.showFlash() } - .onDisappear { - self.task?.cancel() - self.task = nil + .onChange(of: self.scenePhase) { _, newValue in + guard newValue != .active else { return } + self.clearFlash() } + .onDisappear { self.clearFlash() } + } + + private func showFlash() { + withAnimation(.easeOut(duration: 0.08)) { + self.opacity = 0.85 + } + self.dismissGate.schedule(after: .milliseconds(110)) { + withAnimation(.easeOut(duration: 0.32)) { + self.opacity = 0 + } + } + } + + private func clearFlash() { + self.opacity = 0 + self.dismissGate.cancel() } } diff --git a/apps/ios/Sources/Services/DelayedActionGate.swift b/apps/ios/Sources/Services/DelayedActionGate.swift new file mode 100644 index 000000000000..6ffc2266d20b --- /dev/null +++ b/apps/ios/Sources/Services/DelayedActionGate.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Owns one delayed MainActor action and invalidates it by generation. +/// +/// Cancellation alone is not an action-time guarantee: a delay can finish +/// before a replacement cancels the task. The generation check therefore runs +/// on the MainActor immediately before the action is delivered. +@MainActor +final class DelayedActionGate { + typealias Sleeper = @Sendable (Duration) async throws -> Void + typealias DeliveryBarrier = @Sendable () async -> Void + + private let sleeper: Sleeper + private let deliveryBarrier: DeliveryBarrier + private var generation: UInt64 = 0 + private var task: Task? + + init( + sleeper: @escaping Sleeper = { try await Task.sleep(for: $0) }, + deliveryBarrier: @escaping DeliveryBarrier = {}) + { + self.sleeper = sleeper + self.deliveryBarrier = deliveryBarrier + } + + @discardableResult + func schedule( + after duration: Duration, + action: @escaping @MainActor () async -> Void) -> Task + { + self.task?.cancel() + self.generation &+= 1 + let generation = self.generation + let sleeper = self.sleeper + let deliveryBarrier = self.deliveryBarrier + let task = Task { @MainActor [weak self] in + do { + try await sleeper(duration) + } catch { + return + } + await deliveryBarrier() + guard let self, + !Task.isCancelled, + self.generation == generation + else { return } + await action() + if self.generation == generation { + self.task = nil + } + } + self.task = task + return task + } + + func cancel() { + self.generation &+= 1 + self.task?.cancel() + self.task = nil + } +} diff --git a/apps/ios/Tests/DelayedActionGateTests.swift b/apps/ios/Tests/DelayedActionGateTests.swift new file mode 100644 index 000000000000..91cb5ebd0cda --- /dev/null +++ b/apps/ios/Tests/DelayedActionGateTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import OpenClaw + +private actor DelayDeliveryBarrier { + private var deliveryCount = 0 + private var firstDeliveryPaused = false + private var pauseWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func pauseFirstDelivery() async { + self.deliveryCount += 1 + guard self.deliveryCount == 1 else { return } + self.firstDeliveryPaused = true + let pauseWaiters = self.pauseWaiters + self.pauseWaiters.removeAll() + for waiter in pauseWaiters { + waiter.resume() + } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitUntilFirstDeliveryIsPaused() async { + if self.firstDeliveryPaused { return } + await withCheckedContinuation { continuation in + self.pauseWaiters.append(continuation) + } + } + + func releaseFirstDelivery() { + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + for waiter in releaseWaiters { + waiter.resume() + } + } +} + +struct DelayedActionGateTests { + @Test @MainActor func `completed delay delivers its action`() async { + var actions: [String] = [] + let gate = DelayedActionGate(sleeper: { _ in }) + + let task = gate.schedule(after: .zero) { + actions.append("current") + } + await task.value + + #expect(actions == ["current"]) + } + + @Test @MainActor func `cancellation after delay return suppresses delivery`() async { + var actions: [String] = [] + let barrier = DelayDeliveryBarrier() + let gate = DelayedActionGate( + sleeper: { _ in }, + deliveryBarrier: { await barrier.pauseFirstDelivery() }) + + let task = gate.schedule(after: .zero) { + actions.append("stale") + } + await barrier.waitUntilFirstDeliveryIsPaused() + gate.cancel() + await barrier.releaseFirstDelivery() + await task.value + + #expect(actions.isEmpty) + } + + @Test @MainActor func `replacement action wins after stale delay returns`() async { + var actions: [String] = [] + let barrier = DelayDeliveryBarrier() + let gate = DelayedActionGate( + sleeper: { _ in }, + deliveryBarrier: { await barrier.pauseFirstDelivery() }) + + let staleTask = gate.schedule(after: .zero) { + actions.append("stale") + } + await barrier.waitUntilFirstDeliveryIsPaused() + let currentTask = gate.schedule(after: .zero) { + actions.append("current") + } + await currentTask.value + await barrier.releaseFirstDelivery() + await staleTask.value + + #expect(actions == ["current"]) + } +} diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index ccd85aca13ef..7401d6548396 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -4,6 +4,35 @@ import Testing @testable import OpenClaw struct RootTabsSourceGuardTests { + @Test func `inactive scenes clear voice wake toast and camera flash`() throws { + let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let rootAppearLifecycle = try Self.extract( + source, + from: "private func rootAppearLifecycle(_ content: some View) -> some View", + to: "private func rootGatewayProblemLifecycle(_ content: some View) -> some View") + let rootVoiceWakeLifecycle = try Self.extract( + source, + from: "private func rootVoiceWakeLifecycle(_ content: some View) -> some View", + to: "private func rootAppearLifecycle(_ content: some View) -> some View") + let cameraFlashOverlay = try Self.extract( + source, + from: "private struct RootCameraFlashOverlay: View", + to: "#if DEBUG") + + #expect(source.contains("@State private var toastDismissGate = DelayedActionGate()")) + #expect(rootVoiceWakeLifecycle.contains("self.toastDismissGate.schedule")) + #expect(!source.contains("toastDismissTask")) + #expect(rootAppearLifecycle.contains("guard newValue == .active else {")) + #expect(rootAppearLifecycle.contains("self.clearVoiceWakeToast()")) + #expect(cameraFlashOverlay.contains("@Environment(\\.scenePhase) private var scenePhase")) + #expect(cameraFlashOverlay.contains("@State private var dismissGate = DelayedActionGate()")) + #expect(cameraFlashOverlay.contains("self.dismissGate.schedule")) + #expect(!cameraFlashOverlay.contains("Task {")) + #expect(cameraFlashOverlay.contains("guard self.scenePhase == .active else {")) + #expect(cameraFlashOverlay.contains("guard newValue != .active else { return }")) + #expect(cameraFlashOverlay.contains("self.clearFlash()")) + } + @Test func `app applies initial scene phase before gateway admission`() throws { let source = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8) let startupTask = try Self.extract(